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:
2026-07-04 01:00:22 +08:00
parent 51eafb23f3
commit 876789fac1
93 changed files with 8803 additions and 669 deletions

View File

@@ -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)