feat(auth): 平台成唯一账号源——小程序微信登录进 users + OPC 报名资料字段

统一用户账号体系(阶段1,非破坏):
- UserRepository 新增 get_by_wx_mini_openid。
- /auth/wx-login:小程序登录(X-Client=miniprogram)按 wx_mini_openid 建/复用
  users 账号(role=opc_member, source=mini_program, auth_type=wx_openid),
  并带 nickName/avatarUrl;非小程序仍走 wx_openid。
- /auth/me + 登录响应补齐小程序契约字段 name/phone/phoneBound。
- users 增 opc_status/topics 列(alembic 0010);desktop/web profile 也输出
  name/phone/phoneBound/status/topics,且 update-profile 支持 name/status/topics 映射。
- scripts/db/seed.py 增 accounts→users 幂等迁移:旧小程序账号并入全局 users
  (wx_mini_openid/phone 关联 + opc_member 身份),由用户执行 migrate+seed。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-25 19:59:34 +08:00
parent ea904d3a50
commit 9d60e0afc6
6 changed files with 168 additions and 4 deletions
+42 -4
View File
@@ -7,6 +7,7 @@
"""
from __future__ import annotations
import json
import re
import secrets
@@ -36,6 +37,21 @@ from ...services import sms, wechat
router = APIRouter(prefix="/auth", tags=["auth"])
def _parse_topics(raw) -> list:
"""topics(JSON 文本/list) → list;失败兜底 []。"""
if not raw:
return []
if isinstance(raw, list):
return raw
if isinstance(raw, str):
try:
v = json.loads(raw)
return v if isinstance(v, list) else []
except (TypeError, ValueError):
return []
return []
# 用户来源(X-Client 头,回退 body.source;未指认则按 auth_type 推断)
_SOURCE_BY_CLIENT = {
"miniprogram": "mini_program", "mini_program": "mini_program", "mp": "mini_program",
@@ -147,6 +163,11 @@ async def _profile_for(
region_id = (identity or user).get("region_id")
profile = {f: user.get(f, "") for f in _PROFILE_FIELDS}
profile.update(username=user.get("username", ""))
profile["name"] = user.get("nickname") or user.get("username", "")
profile["phone"] = user.get("phone", "")
profile["phoneBound"] = bool(user.get("phone", ""))
profile["status"] = user.get("opc_status", "")
profile["topics"] = _parse_topics(user.get("topics", ""))
profile["source"] = user.get("source", "")
profile["auth_type"] = user.get("auth_type", "")
profile["company"] = user.get("company", "")
@@ -312,6 +333,14 @@ async def update_profile(
raise HTTPException(status_code=400, detail="Password cannot be empty")
profile_updates = {k: payload[k] for k in _PROFILE_FIELDS if k in payload}
# 小程序契约字段 → users 列映射(name→nicknamestatus→opc_statustopics→JSON文本)
if "name" in payload:
profile_updates["nickname"] = payload["name"]
if "status" in payload:
profile_updates["opc_status"] = payload["status"]
if "topics" in payload:
t = payload["topics"]
profile_updates["topics"] = json.dumps(t, ensure_ascii=False) if isinstance(t, list) else (t or "")
changing_credentials = new_username is not None or new_password is not None
if not profile_updates and not changing_credentials:
raise HTTPException(status_code=400, detail="Nothing to update")
@@ -467,13 +496,22 @@ async def wx_login(req: WxLoginRequest, request: Request, db: Database = Depends
except wechat.WechatError as exc:
raise HTTPException(status_code=401, detail=str(exc))
user = await db.users.get_by_wx_openid(openid)
source = _client_source(request, getattr(req, "source", ""), "wx_openid")
is_mini = source == "mini_program"
# 小程序登录存 wx_mini_openid(与网页/公众号 openid 不同);否则存 wx_openid
if is_mini:
user = await db.users.get_by_wx_mini_openid(openid) or await db.users.get_by_wx_openid(openid)
else:
user = await db.users.get_by_wx_openid(openid)
if user is None:
user = await db.users.create(
f"wx_{openid[:24]}", password=secrets.token_hex(16),
wx_openid=openid, role="opc_member",
source=_client_source(request, getattr(req, "source", ""), "wx_openid"),
auth_type="wx_openid", register_ip=_client_ip(request),
wx_openid=openid if not is_mini else "",
wx_mini_openid=openid if is_mini else "",
nickname=str(getattr(req, "nickName", "") or ""),
avatar=str(getattr(req, "avatarUrl", "") or ""),
role="opc_member", source=source, auth_type="wx_openid",
register_ip=_client_ip(request),
)
if user.get("status") != "active":
raise HTTPException(status_code=403, detail="账号已禁用")
+9
View File
@@ -39,6 +39,10 @@ class UpdateProfileRequest(BaseModel):
expires_in: int | None = None
# 演示用户资料字段(账户管理页可编辑)
nickname: str | None = None
# 小程序契约字段(name→nicknamestatus→opc_statustopics→JSON 列表)
name: str | None = None
status: str | None = None
topics: list[str] | None = None
account: str | None = None
company: str | None = None
room: str | None = None
@@ -66,6 +70,9 @@ class UserProfile(BaseModel):
username: str = ""
nickname: str = ""
name: str = "" # 别名 = nickname(小程序契约:显示名 / 报名资料姓名)
phone: str = "" # 已绑定手机号
phoneBound: bool = False # 是否已绑手机(小程序「已绑定手机」徽标/绑定门槛)
account: str = ""
company: str = ""
room: str = ""
@@ -122,6 +129,8 @@ class PhoneLoginRequest(BaseModel):
class WxLoginRequest(BaseModel):
code: str = Field(description="wx.login 返回的 code(未配置 appid 时当作 openid 直通)")
nickName: str = ""
avatarUrl: str = ""
class WxPhoneRequest(BaseModel):