feat(training): 培训子应用接入统一账号——业务鉴权改验平台 JWT + 读写平台 users

阶段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 <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-25 20:22:24 +08:00
parent 6b0c3ba134
commit 8a00712750
+71 -104
View File
@@ -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"])