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:
+71
-104
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user