From 9c4908fb87eb976997e66944abd0220d9cf92551 Mon Sep 17 00:00:00 2001 From: Pine Date: Fri, 28 Aug 2026 17:17:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=81=9C=E7=94=A8=E8=B4=A6?= =?UTF-8?q?=E5=AF=86=E7=99=BB=E5=BD=95=20+=20=E6=89=AB=E7=A0=81=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=9C=89=E6=95=88=E6=9C=9F=E4=B8=8B=E5=8F=91=20+=20?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0=E5=88=B6=E8=BA=AB=E4=BB=BD=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /auth/login 全端口停用(403 引导手机号/小程序扫码登录) - 扫码会话接口下发 expires_in(前端二维码倒计时) - role_allowed 叠加制身份:所有账号自带 OPC 基础身份,operator/carrier 仅业务叠加,35 处 C 端端点统一放行 --- app/api/routers/auth.py | 20 +++++++++++++++----- app/domain/rules.py | 9 ++++++++- app/services/wechat.py | 1 + 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 6bf05b6..5d96211 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -96,7 +96,7 @@ def _client_ip(request: Request) -> str: xff = request.headers.get("X-Forwarded-For", "") if xff: return xff.split(",")[0].strip() - return request.client.host if (request.client and request.client.address) else "" + return request.client.host if request.client else "" # 账号统一使用手机号作为登录账号(11 位,1 开头) _PHONE_RE = re.compile(r"^1\d{10}$") @@ -198,6 +198,11 @@ async def login(req: LoginRequest, db: Database = Depends(get_db)): - 多个身份:签发中性账号令牌,前端展示身份选择,经 ``/auth/select-identity`` 切换到指定身份后进入对应端口。 """ + # 账号密码登录已全端口停用:统一手机号验证码 / 小程序扫码登录(身份叠加制,角色仅是账号上的业务绑定) + raise HTTPException( + status_code=403, + detail="账号密码登录已停用,请使用手机号验证码或小程序扫码登录", + ) user = await db.users.get_by_username(req.username) if user is None or not await db.users.verify_password(user, req.password): raise HTTPException(status_code=401, detail="Invalid username or password") @@ -313,7 +318,12 @@ async def update_profile( if new_password is not None and not new_password.strip(): raise HTTPException(status_code=400, detail="Password cannot be empty") + from ...infrastructure.oss import to_object_path profile_updates = {k: payload[k] for k in _PROFILE_FIELDS if k in payload} + # 头像等资源字段入库归一化:完整 URL → 对象路径(/oss/),出口再生成 URL + for _k in ("avatar", "company_avatar"): + if _k in profile_updates: + profile_updates[_k] = to_object_path(profile_updates[_k]) # 小程序契约字段 → users 列映射(name→nickname,status→opc_status,topics→JSON文本) if "name" in payload: profile_updates["nickname"] = payload["name"] @@ -717,11 +727,11 @@ async def mp_qr_start(request: Request, db: Database = Depends(get_db)): try: png = await wechat.get_wxacode(scene, page="pages/scan-login/index") b64 = base64.b64encode(png).decode("ascii") - return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True} + return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True, "expires_in": wx_qr_store.TTL_SECONDS} except wechat.WechatError: # 无凭据/失败兜底:返回可扫描的 URL,前端按 qr_url 渲染普通二维码 qr_url = f"https://opc.pinesound.cn/mp/login?scene={scene}" - return {"scene": scene, "qr_url": qr_url, "mp_enabled": False} + return {"scene": scene, "qr_url": qr_url, "mp_enabled": False, "expires_in": wx_qr_store.TTL_SECONDS} @router.get("/mp-qr/poll", summary="轮询小程序扫码登录状态") @@ -747,10 +757,10 @@ async def mp_qr_bind_start(request: Request, db: Database = Depends(get_db), use try: png = await wechat.get_wxacode(scene, page="pages/scan-login/index") b64 = base64.b64encode(png).decode("ascii") - return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True} + return {"scene": scene, "qr_image": f"data:image/png;base64,{b64}", "mp_enabled": True, "expires_in": wx_qr_store.TTL_SECONDS} except wechat.WechatError: qr_url = f"https://opc.pinesound.cn/mp/bind?scene={scene}" - return {"scene": scene, "qr_url": qr_url, "mp_enabled": False} + return {"scene": scene, "qr_url": qr_url, "mp_enabled": False, "expires_in": wx_qr_store.TTL_SECONDS} @router.post("/mp-qr/confirm", summary="小程序内确认扫码登录/绑定") diff --git a/app/domain/rules.py b/app/domain/rules.py index adc7154..0202b33 100644 --- a/app/domain/rules.py +++ b/app/domain/rules.py @@ -17,7 +17,14 @@ def is_platform_admin(user: dict) -> bool: def role_allowed(user: dict, *roles: str) -> bool: - """当前用户业务角色是否 ∈ 允许角色集合。""" + """当前用户业务角色是否 ∈ 允许角色集合。 + + 身份模型(叠加制,非割裂):每个账号都自带「OPC 基础身份」——运营方(operator)、 + 载体方(carrier) 等只是在其上的业务角色叠加。因此凡要求 opc_member 的能力 + (C 端通用能力:浏览/点赞/留言/报名/接单…),任意登录账号均具备。 + """ + if "opc_member" in roles: + return True return user.get("role") in set(roles) diff --git a/app/services/wechat.py b/app/services/wechat.py index 07eb420..b2a0fae 100644 --- a/app/services/wechat.py +++ b/app/services/wechat.py @@ -186,6 +186,7 @@ class WxQrSessionStore: """ _TTL = 120 # 会话有效期(秒) + TTL_SECONDS = _TTL # 对外下发(前端二维码倒计时) def __init__(self) -> None: self._data: dict[str, dict] = {}