feat: multi-tenant workspace isolation, RBAC, sidebar nav, billing, and Android enhancements
- Backend: workspace_id isolation for 14 model tables + safe migration/backfill - Backend: RBAC system with 4 roles and 23 permissions, seeded on startup - Backend: workspace admin endpoints (list/manage all workspaces) - Backend: admin user management API (CRUD, reset password) - Backend: billing API with subscription plans, usage tracking, rate limiting - Backend: fix system_logs.py UNION query and wrong column references - Backend: WebSocket JWT auth and workspace enforcement - Frontend: sidebar navigation replacing top dropdown menu - Frontend: user management page (Users.vue) for admins - Frontend: enhanced Workspaces.vue with admin table view - Frontend: workspace RBAC computed properties in user store - Android: agent marketplace, billing/subscription UI, onboarding wizard - Android: phone login, analytics tracker, crash handler, network diagnostics - Android: splash screen, encrypted token storage, app update enhancements - Docs: multi-tenant RBAC guide with 8 sections and role-permission matrix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -585,19 +585,256 @@ async def bind_phone(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""绑定或修改手机号。"""
|
||||
"""绑定或修改手机号(需验证码校验)。"""
|
||||
redis = get_redis_client()
|
||||
|
||||
# 必须提供验证码
|
||||
if not body.code:
|
||||
raise HTTPException(status_code=400, detail="请提供短信验证码")
|
||||
|
||||
# 校验验证码
|
||||
if not _verify_sms_code(redis, body.phone, body.code):
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
|
||||
# 检查手机号是否已被其他用户绑定
|
||||
existing = db.query(User).filter(User.phone == body.phone, User.id != current_user.id).first()
|
||||
if existing:
|
||||
raise ConflictError("该手机号已被其他用户绑定")
|
||||
|
||||
current_user.phone = body.phone
|
||||
current_user.phone_verified = True
|
||||
current_user.phone_verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
logger.info("用户 %s 绑定手机号成功", current_user.username)
|
||||
return {"message": "手机号绑定成功", "phone": body.phone}
|
||||
|
||||
|
||||
# ─── 手机验证码(v1.1.0)────────────────────────────────────
|
||||
|
||||
SMS_CODE_TTL_SEC = 600 # 验证码 10 分钟有效
|
||||
SMS_RATE_LIMIT_SEC = 60 # 同一手机号 60 秒内只能发一次
|
||||
|
||||
|
||||
class SendSmsCodeRequest(BaseModel):
|
||||
phone: str
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def phone_format(cls, v: str) -> str:
|
||||
if not re.match(r"^1[3-9]\d{9}$", v):
|
||||
raise ValueError("手机号格式无效")
|
||||
return v
|
||||
|
||||
|
||||
class VerifyPhoneRequest(BaseModel):
|
||||
phone: str
|
||||
code: str
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def phone_format(cls, v: str) -> str:
|
||||
if not re.match(r"^1[3-9]\d{9}$", v):
|
||||
raise ValueError("手机号格式无效")
|
||||
return v
|
||||
|
||||
|
||||
class PhoneLoginRequest(BaseModel):
|
||||
phone: str
|
||||
code: str
|
||||
|
||||
@field_validator("phone")
|
||||
@classmethod
|
||||
def phone_format(cls, v: str) -> str:
|
||||
if not re.match(r"^1[3-9]\d{9}$", v):
|
||||
raise ValueError("手机号格式无效")
|
||||
return v
|
||||
|
||||
|
||||
def _gen_sms_code() -> str:
|
||||
"""生成 6 位数字验证码"""
|
||||
return str(secrets.randbelow(900000) + 100000)
|
||||
|
||||
|
||||
async def _send_sms_code(phone: str, code: str) -> bool:
|
||||
"""发送短信验证码。返回 True 表示发送成功。"""
|
||||
from app.core.sms_service import get_sms_provider
|
||||
provider = get_sms_provider()
|
||||
try:
|
||||
return await provider.send(phone, code)
|
||||
except Exception as e:
|
||||
logger.warning("SMS 发送异常: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/phone/send-code")
|
||||
async def send_phone_code(body: SendSmsCodeRequest):
|
||||
"""发送手机验证码。"""
|
||||
redis = get_redis_client()
|
||||
|
||||
# 频率限制
|
||||
rate_key = f"sms_rate:{body.phone}"
|
||||
if redis:
|
||||
if redis.exists(rate_key):
|
||||
ttl = redis.ttl(rate_key)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"操作过于频繁,请 {ttl} 秒后重试"
|
||||
)
|
||||
|
||||
code = _gen_sms_code()
|
||||
|
||||
# 存储到 Redis
|
||||
code_key = f"sms_code:{body.phone}"
|
||||
if redis:
|
||||
redis.setex(code_key, SMS_CODE_TTL_SEC, code)
|
||||
redis.setex(rate_key, SMS_RATE_LIMIT_SEC, "1")
|
||||
else:
|
||||
# 无 Redis 时回退内存存储
|
||||
if not hasattr(send_phone_code, "_memory_store"):
|
||||
send_phone_code._memory_store = {}
|
||||
send_phone_code._memory_rate = {}
|
||||
send_phone_code._memory_store[body.phone] = {
|
||||
"code": code,
|
||||
"expires_at": datetime.utcnow() + timedelta(seconds=SMS_CODE_TTL_SEC),
|
||||
}
|
||||
send_phone_code._memory_rate[body.phone] = \
|
||||
datetime.utcnow() + timedelta(seconds=SMS_RATE_LIMIT_SEC)
|
||||
|
||||
# 发送验证码
|
||||
sent = await _send_sms_code(body.phone, code)
|
||||
|
||||
if not sent:
|
||||
# Mock 模式或发送失败时返回验证码(仅开发环境)
|
||||
logger.info("开发模式:%s 的短信验证码为 %s", body.phone, code)
|
||||
return {
|
||||
"message": "验证码已生成(Mock 模式)",
|
||||
"dev_code": code,
|
||||
}
|
||||
|
||||
return {"message": "验证码已发送"}
|
||||
|
||||
|
||||
def _verify_sms_code(redis, phone: str, code: str) -> bool:
|
||||
"""校验短信验证码(从 Redis 或内存存储中读取)。"""
|
||||
code_key = f"sms_code:{phone}"
|
||||
|
||||
stored_code = None
|
||||
if redis:
|
||||
stored_code_raw = redis.get(code_key)
|
||||
stored_code = stored_code_raw.decode() if isinstance(stored_code_raw, bytes) else stored_code_raw
|
||||
elif hasattr(send_phone_code, "_memory_store"):
|
||||
entry = send_phone_code._memory_store.get(phone, {})
|
||||
if entry and entry.get("expires_at", datetime.min) > datetime.utcnow():
|
||||
stored_code = entry.get("code")
|
||||
|
||||
if not stored_code:
|
||||
return False
|
||||
|
||||
if stored_code != code.strip():
|
||||
return False
|
||||
|
||||
# 验证通过后清除
|
||||
if redis:
|
||||
redis.delete(code_key)
|
||||
elif hasattr(send_phone_code, "_memory_store"):
|
||||
send_phone_code._memory_store.pop(phone, None)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@router.post("/phone/verify")
|
||||
async def verify_phone(
|
||||
body: VerifyPhoneRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""验证手机号(绑定后验证)。"""
|
||||
redis = get_redis_client()
|
||||
|
||||
if not _verify_sms_code(redis, body.phone, body.code):
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
|
||||
# 检查手机号是否已被其他用户绑定
|
||||
existing = db.query(User).filter(
|
||||
User.phone == body.phone, User.id != current_user.id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="该手机号已被其他用户绑定")
|
||||
|
||||
current_user.phone = body.phone
|
||||
current_user.phone_verified = True
|
||||
current_user.phone_verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
logger.info("用户 %s 手机号 %s 验证成功", current_user.username, body.phone)
|
||||
return {"message": "手机号验证成功", "phone": body.phone}
|
||||
|
||||
|
||||
@router.post("/login/phone", response_model=Token)
|
||||
async def phone_login(
|
||||
body: PhoneLoginRequest,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""手机号+验证码登录。用户不存在则自动注册。"""
|
||||
redis = get_redis_client()
|
||||
|
||||
if not _verify_sms_code(redis, body.phone, body.code):
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
|
||||
user = db.query(User).filter(User.phone == body.phone).first()
|
||||
|
||||
if not user:
|
||||
# 自动注册:用手机号生成用户名
|
||||
username_base = f"user_{body.phone[-6:]}"
|
||||
username = username_base
|
||||
counter = 1
|
||||
while db.query(User).filter(User.username == username).first():
|
||||
username = f"{username_base}_{counter}"
|
||||
counter += 1
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=f"{body.phone}@phone.local",
|
||||
password_hash=get_password_hash(secrets.token_urlsafe(16)),
|
||||
phone=body.phone,
|
||||
phone_verified=True,
|
||||
phone_verified_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
logger.info("手机号自动注册: %s → %s", body.phone, username)
|
||||
else:
|
||||
# 如果手机号还没标记为已验证,更新
|
||||
if not user.phone_verified:
|
||||
user.phone_verified = True
|
||||
user.phone_verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
if user.status == "deleted":
|
||||
raise HTTPException(status_code=403, detail="该账号已注销")
|
||||
|
||||
ws_id = _get_user_default_workspace_id(db, user)
|
||||
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.id, "username": user.username, "ws": ws_id or ""},
|
||||
expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES),
|
||||
)
|
||||
|
||||
refresh_token = create_refresh_token(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
workspace_id=ws_id or "",
|
||||
)
|
||||
|
||||
return {
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer",
|
||||
"refresh_token": refresh_token,
|
||||
}
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
token: str | None = Depends(oauth2_scheme_optional),
|
||||
db: Session = Depends(get_db)
|
||||
|
||||
Reference in New Issue
Block a user