From 8a007127503bbfc5ae393b638a3cc58f7e0b561d Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 25 Aug 2026 20:22:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(training):=20=E5=9F=B9=E8=AE=AD=E5=AD=90?= =?UTF-8?q?=E5=BA=94=E7=94=A8=E6=8E=A5=E5=85=A5=E7=BB=9F=E4=B8=80=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E2=80=94=E2=80=94=E4=B8=9A=E5=8A=A1=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E6=94=B9=E9=AA=8C=E5=B9=B3=E5=8F=B0=20JWT=20+=20=E8=AF=BB?= =?UTF-8?q?=E5=86=99=E5=B9=B3=E5=8F=B0=20users?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阶段3(统一账号落地,小程序侧): - require_auth 改为解平台 JWT(app.jwt.decode_access_token),返回 {username,sub}; 新增 _current_user(按 sub/username 取 users)、_user_payload(读 users 昵称/手机/ opc_status/topics/source)、_set_user_phone。 - me/update-profile/bind-phone/wx-phone 改为读写平台 users;绑定手机用平台 sms 校验 (platform_sms.verify) + 写 users.phone;update-profile 映射 name→nickname、 status→opc_status、topics→JSON。 - 业务(报名/我的报名/查报名/签到)改从 _current_user 取手机号等资料。 - 移除培训端自带登录:register/login/send-code/phone-login/wx-login 返回 410, 防止再产生 accounts 双套账号(统一走平台 /auth)。 Co-Authored-By: Claude --- app/training/main.py | 175 ++++++++++++++++++------------------------- 1 file changed, 71 insertions(+), 104 deletions(-) diff --git a/app/training/main.py b/app/training/main.py index d1d1d73..9c860ae 100644 --- a/app/training/main.py +++ b/app/training/main.py @@ -24,6 +24,8 @@ from ..infrastructure.db import AsyncSessionLocal from ..infrastructure.repositories import Database from ..services.task_service import TaskService from ..api.routers.auth import _ensure_opc_identity +from ..jwt import decode_access_token +from ..services import sms as platform_sms @asynccontextmanager @@ -106,11 +108,22 @@ def now_iso(): def require_auth(authorization: str): + """校验并返回当前用户(统一账号)。令牌为平台 JWT(/auth/* 登录签发)。""" token = (authorization or "").removeprefix("Bearer ").strip() - payload = verify_token(token) + payload = decode_access_token(token) if not payload: raise HTTPException(status_code=401, detail="未授权或登录已过期") - return payload + return {"username": payload.get("username", ""), "sub": payload.get("sub", ""), "user_id": payload.get("sub", "")} + + +def _current_user(payload: dict) -> dict | None: + """按平台 JWT( sub/username )取平台 users 行(统一账号源)。""" + u = None + if payload.get("sub"): + u = db.fetch_one("users", id=payload["sub"]) + if u is None: + u = db.fetch_one("users", username=payload.get("username")) + return u # ================= 工具 ================= @@ -140,20 +153,27 @@ def _count_enrolled(event_id): return n -def _user_payload(acct): - """统一构建 /me 与 /update-profile 返回的用户资料(含报名资料)""" +def _user_payload(u): + """统一构建 /me 与 /update-profile 返回的用户资料(读取平台 users,含报名资料)""" return { - "username": acct["username"], - "name": acct.get("name") or acct["username"], - "avatar": abs_url(acct.get("avatar")), - "phone": acct.get("phone") or "", - "phoneBound": bool(acct.get("phone_bound")), - "status": acct.get("status_label") or "", - "topics": _parse_topics(acct.get("topics")), - "source": acct.get("source") or "", + "username": u.get("username", ""), + "name": u.get("nickname") or u.get("username", ""), + "avatar": abs_url(u.get("avatar", "")), + "phone": u.get("phone", "") or "", + "phoneBound": bool(u.get("phone", "")), + "status": u.get("opc_status", "") or "", + "topics": _parse_topics(u.get("topics", "")), + "source": u.get("source", "") or "", } +def _set_user_phone(username: str, phone: str) -> None: + """把手机号写入平台 users(统一账号)。""" + u = db.fetch_one("users", username=username) or db.fetch_one("users", id=username) + if u: + db.update_row("users", u["id"], {"phone": phone}) + + def jsonify(obj): # 处理 datetime 等 → dict return obj @@ -173,18 +193,9 @@ async def health(): @app.post("/api/auth/register") -async def register(req: Request): - b = await req.json() - username = str(b.get("username", "")).strip() - password = str(b.get("password", "")) - name = str(b.get("name", "")).strip() - contact = str(b.get("contact", "")).strip() - if not username or len(username) < 2: - raise HTTPException(400, "账号至少 2 个字符") - if len(password) < 4: - raise HTTPException(400, "密码至少 4 个字符") - if await asyncio.to_thread(db.fetch_one, "accounts", username=username): - raise HTTPException(400, "账号已存在,请直接登录") +async def register(): + # 统一账号:注册改由平台 /auth/register 等,避免双套账号 + raise HTTPException(410, "请使用 /auth(统一账号)") entry = {"id": db.gen_id("U"), "username": username, "password": hash_password(password), "name": name or username, "contact": contact, "identities": json.dumps(["opc_member|certified"]), "created_at": now_iso()} @@ -193,20 +204,9 @@ async def register(req: Request): @app.post("/api/auth/login") -async def login(req: Request): - b = await req.json() - username = str(b.get("username", "")).strip() - password = str(b.get("password", "")) - if username == "pine": - if password == "123456": - return {"ok": True, "token": make_token("pine"), "username": "pine", "name": "Pine"} - raise HTTPException(401, "密码错误,请重新输入") - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=username) - if not acct: - raise HTTPException(404, "账号不存在,请先注册") - if acct["password"] != hash_password(password): - raise HTTPException(401, "密码错误,请重新输入") - return {"ok": True, "token": make_token(username), "username": username, "name": acct.get("name") or username} +async def login(): + # 统一账号:登录改由平台 /auth/login 等,避免双套账号 + raise HTTPException(410, "登录请使用 /auth(统一账号)") # ============================= 扫码接单(任务中心)============================= @@ -266,13 +266,9 @@ async def tasks_my_tasks(request: Request): @app.post("/api/auth/send-code") -async def send_code(req: Request): - b = await req.json() - phone = str(b.get("phone", "")).strip() - if not PHONE_RE.match(phone): - raise HTTPException(400, "请输入正确的 11 位手机号") - code = send_sms_code(phone) - return {"ok": True, "sent": True, "debugCode": code} +async def send_code(): + # 统一账号:验证码改由平台 /auth/send-code 下发,避免双套账号 + raise HTTPException(410, "登录请使用 /auth/send-code(统一账号)") def _same_phone_accounts(phone): @@ -345,11 +341,9 @@ def auth_by_phone(body: dict): @app.post("/api/auth/phone-login") -async def phone_login(req: Request): - b = await req.json() - acct, is_new = await asyncio.to_thread(auth_by_phone, b) - return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"], - "name": acct.get("name") or acct["username"], "isNew": is_new} +async def phone_login(): + # 统一账号:手机号登录改由平台 /auth/phone-login,避免双套账号 + raise HTTPException(410, "登录请使用 /auth/phone-login(统一账号)") @app.post("/api/auth/logout") @@ -365,43 +359,18 @@ async def verify(authorization: str = Header(default="")): # -------- 微信登录 / 我的 / 绑定手机 -------- @app.post("/api/auth/wx-login") -async def wx_login(req: Request): - b = await req.json() - code = str(b.get("code", "")) - if not code: - raise HTTPException(400, "缺少微信登录凭证") - - # 严格真实登录:必须配置有效 AppID/Secret,且 code2session 成功拿到 openid,否则一律明确失败(绝不用伪 openid 冒充) - if not WX_HAS_CONFIG: - raise HTTPException(500, "后端未配置微信 AppID/Secret,无法进行微信登录") - try: - wresp = await _wx_code2session(code) - except Exception: - raise HTTPException(502, "微信服务调用失败,请稍后重试") - if wresp.get("errcode") or not wresp.get("openid"): - errmsg = wresp.get("errmsg", "invalid code") - raise HTTPException(401, f"微信登录失败:{errmsg}") - openid = wresp.get("openid") - - acct = await asyncio.to_thread(db.fetch_one, "accounts", wxid=openid) - if not acct: - name = str(b.get("nickName", "")).strip() or "微信用户" - acct = {"id": db.gen_id("U"), "username": openid, "wxid": openid, "phone": "", "name": name, - "avatar": str(b.get("avatarUrl", "")), "phone_bound": 0, "identities": json.dumps(["wx"]), - "created_at": now_iso()} - await asyncio.to_thread(db.insert, "accounts", acct) - return {"ok": True, "token": make_token(acct["username"]), "username": acct["username"], - "name": acct.get("name") or "微信用户", "avatar": abs_url(acct.get("avatar")), "phoneBound": bool(acct.get("phone_bound")), - "configured": WX_HAS_CONFIG} +async def wx_login(): + # 统一账号:微信登录改由平台 /auth/wx-login,避免双套账号 + raise HTTPException(410, "登录请使用 /auth/wx-login(统一账号)") @app.get("/api/auth/me") async def me(authorization: str = Header(default="")): payload = require_auth(authorization) - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) - if not acct: + u = await asyncio.to_thread(_current_user, payload) + if not u: raise HTTPException(404, "账号不存在") - return {"ok": True, "user": _user_payload(acct)} + return {"ok": True, "user": _user_payload(u)} @app.post("/api/auth/update-profile") @@ -409,24 +378,24 @@ async def update_profile(req: Request, authorization: str = Header(default="")): """更新当前用户资料:昵称/头像 + 报名资料(status/topics/source)""" payload = require_auth(authorization) b = await req.json() - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) - if not acct: + u = await asyncio.to_thread(_current_user, payload) + if not u: raise HTTPException(404, "账号不存在") patch = {} if b.get("name") is not None and str(b["name"]).strip(): - patch["name"] = str(b["name"]).strip() + patch["nickname"] = str(b["name"]).strip() if b.get("avatar") is not None: patch["avatar"] = str(b["avatar"]).strip() if b.get("status") is not None: - patch["status_label"] = str(b["status"]).strip() + patch["opc_status"] = str(b["status"]).strip() if b.get("source") is not None: patch["source"] = str(b["source"]).strip() if b.get("topics") is not None: patch["topics"] = json.dumps(b["topics"], ensure_ascii=False) if isinstance(b["topics"], list) else str(b["topics"]) if patch: - await asyncio.to_thread(db.update_row, "accounts", acct["id"], patch) - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) - return {"ok": True, "user": _user_payload(acct)} + await asyncio.to_thread(db.update_row, "users", u["id"], patch) + u = await asyncio.to_thread(_current_user, payload) + return {"ok": True, "user": _user_payload(u)} @app.post("/api/auth/wx-phone") @@ -451,11 +420,10 @@ async def wx_phone(req: Request, authorization: str = Header(default="")): phone = (data.get("phone_info") or {}).get("purePhoneNumber", "") if not phone: raise HTTPException(400, "未获取到手机号") - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) - if not acct: + u = await asyncio.to_thread(_current_user, payload) + if not u: raise HTTPException(404, "账号不存在") - # 绑号即合并:若该手机号下有其它账号(如网页手机号登录账号),把其数据并入当前微信账号 - acct = await asyncio.to_thread(_adopt_phone, acct, phone) + await asyncio.to_thread(_set_user_phone, u["username"], phone) return {"ok": True, "phoneBound": True, "phone": phone} @@ -467,13 +435,12 @@ async def bind_phone(req: Request, authorization: str = Header(default="")): code = str(b.get("code", "")).strip() if not PHONE_RE.match(phone): raise HTTPException(400, "请输入正确的 11 位手机号") - if not check_sms_code(phone, code): + if not platform_sms.verify(phone, code): raise HTTPException(401, "验证码错误或已过期") - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) - if not acct: + u = await asyncio.to_thread(_current_user, payload) + if not u: raise HTTPException(404, "账号不存在") - # 短信绑定同样合并同一手机号下的其它账号 - acct = await asyncio.to_thread(_adopt_phone, acct, phone) + await asyncio.to_thread(_set_user_phone, u["username"], phone) return {"ok": True, "phoneBound": True, "phone": phone} @@ -628,17 +595,17 @@ async def create_booking(req: Request, authorization: str = Header(default="")): # 强制登录:必须携带有效 token(无登录 → 401) payload = require_auth(authorization) - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) + acct = await asyncio.to_thread(_current_user, payload) if not acct: raise HTTPException(401, "登录状态异常,请重新登录") # 报名必须已绑定手机号(登录后自动/引导绑定),保证报名有联系方式 if not acct.get("phone"): raise HTTPException(400, "请先绑定手机号后再报名") - username = acct["username"] + username = payload["username"] # 报名资料(姓名/状态/主题/来源)取账号中已保存的个人中心设置,报名区无需再填;question 按场次从请求体取 - name = (acct.get("name") or "") or (str(b.get("name", "")).strip() or username) - status_label = (acct.get("status_label") or "") or str(b.get("status", "") or "").strip() + name = (acct.get("nickname") or "") or (str(b.get("name", "")).strip() or username) + status_label = (acct.get("opc_status") or "") or str(b.get("status", "") or "").strip() topics_val = _parse_topics(acct.get("topics")) if not topics_val: topics_val = b.get("topics", []) if isinstance(b.get("topics"), list) else [] @@ -670,7 +637,7 @@ def _decode_bk(row): @app.get("/api/bookings/mine") async def my_bookings(authorization: str = Header(default="")): payload = require_auth(authorization) - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) + acct = await asyncio.to_thread(_current_user, payload) ids = {payload["username"]} if acct and acct.get("phone"): ids.add(acct["phone"]) @@ -708,7 +675,7 @@ async def my_bookings(authorization: str = Header(default="")): async def my_booking_for_event(event_id: str, authorization: str = Header(default="")): """查当前登录用户是否已报名该活动及其审核状态(详情页判断用)""" payload = require_auth(authorization) - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) + acct = await asyncio.to_thread(_current_user, payload) ids = {payload["username"]} if acct and acct.get("phone"): ids.add(acct["phone"]) @@ -730,7 +697,7 @@ async def my_booking_for_event(event_id: str, authorization: str = Header(defaul async def checkin(req: Request, authorization: str = Header(default="")): payload = require_auth(authorization) b = await req.json() - acct = await asyncio.to_thread(db.fetch_one, "accounts", username=payload["username"]) + acct = await asyncio.to_thread(_current_user, payload) ids = {payload["username"]} if acct and acct.get("phone"): ids.add(acct["phone"])