From 9d60e0afc6dff4f0faf8e936a1d8badaca71646e Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 25 Aug 2026 19:59:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=B9=B3=E5=8F=B0=E6=88=90?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E8=B4=A6=E5=8F=B7=E6=BA=90=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E5=BE=AE=E4=BF=A1=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=BF=9B=20users=20+=20OPC=20=E6=8A=A5=E5=90=8D=E8=B5=84?= =?UTF-8?q?=E6=96=99=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一用户账号体系(阶段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 --- alembic/versions/0010_user_opc_profile.py | 32 +++++++++++++++ app/api/routers/auth.py | 46 ++++++++++++++++++++-- app/api/schemas/auth.py | 9 +++++ app/infrastructure/models.py | 3 ++ app/infrastructure/repositories.py | 35 +++++++++++++++++ scripts/db/seed.py | 47 +++++++++++++++++++++++ 6 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 alembic/versions/0010_user_opc_profile.py diff --git a/alembic/versions/0010_user_opc_profile.py b/alembic/versions/0010_user_opc_profile.py new file mode 100644 index 0000000..c91f47d --- /dev/null +++ b/alembic/versions/0010_user_opc_profile.py @@ -0,0 +1,32 @@ +"""user opc profile fields (status_label / topics) + +Revision ID: 0010_user_opc_profile +Revises: 0009_task_system_fields +Create Date: 2026-08-25 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0010_user_opc_profile" +down_revision = "0009_task_system_fields" +branch_labels = None +depends_on = None + + +def _add(table, col): + conn = op.get_bind() + cols = {r[1] for r in conn.execute(sa.text(f"PRAGMA table_info({table})"))} + if col.name not in cols: + op.add_column(table, col) + + +def upgrade() -> None: + # OPC 成员报名资料(小程序/网页「报名资料」字段):现状标签 + 关注主题(JSON) + _add("users", sa.Column("opc_status", sa.String, server_default="")) + _add("users", sa.Column("topics", sa.String, server_default="")) + + +def downgrade() -> None: + pass diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 7508057..3c1a470 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -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→nickname,status→opc_status,topics→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="账号已禁用") diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py index 8e88360..016b546 100644 --- a/app/api/schemas/auth.py +++ b/app/api/schemas/auth.py @@ -39,6 +39,10 @@ class UpdateProfileRequest(BaseModel): expires_in: int | None = None # 演示用户资料字段(账户管理页可编辑) nickname: str | None = None + # 小程序契约字段(name→nickname;status→opc_status;topics→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): diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 187f6eb..b6bbe12 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -74,6 +74,9 @@ class User(Base): park_id: Mapped[str] = mapped_column(String, default="") # 所属园区(id) park_name: Mapped[str] = mapped_column(String, default="") # 所属园区(名) account_type: Mapped[str] = mapped_column(String, default="opc_default") # opc_default|park_staff|platform_staff|provider|enterprise + # 小程序/网页「报名资料」字段(现状标签 + 关注主题 JSON) + opc_status: Mapped[str] = mapped_column(String, default="") + topics: Mapped[str] = mapped_column(String, default="") created_at: Mapped[str] = mapped_column(String, default="") updated_at: Mapped[str] = mapped_column(String, default="") diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 7874059..28d7b7d 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -95,6 +95,8 @@ PROFILE_FIELDS = ( "room", "avatar", "company_avatar", + "opc_status", + "topics", ) @@ -135,6 +137,21 @@ def _iso_in(seconds: int) -> str: ).isoformat() +def _parse_topics(raw): + """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 [] + + def _user_to_dict(u: User) -> dict: return { "id": u.id, @@ -175,6 +192,8 @@ def _user_to_dict(u: User) -> dict: "park_id": u.park_id, "park_name": u.park_name, "account_type": u.account_type, + "opc_status": u.opc_status, + "topics": u.topics, "created_at": u.created_at, "updated_at": u.updated_at, } @@ -237,6 +256,8 @@ class UserRepository: source: str = "unknown", auth_type: str = "", register_ip: str = "", + opc_status: str = "", + topics: str = "", compute_provisioned: bool = False, compute_username: str = "", compute_quota: int = 0, @@ -271,6 +292,8 @@ class UserRepository: source=source, auth_type=auth_type, register_ip=register_ip, + opc_status=opc_status, + topics=topics, last_login_ip=register_ip, last_login_at=now, compute_provisioned=compute_provisioned, @@ -330,6 +353,12 @@ class UserRepository: ) return _user_to_dict(u) if u else None + async def get_by_wx_mini_openid(self, wx_mini_openid: str) -> dict | None: + u = await self.session.scalar( + select(User).where(User.wx_mini_openid == wx_mini_openid.strip()), + ) + return _user_to_dict(u) if u else None + async def set_wx_openid(self, user_id: str, wx_openid: str) -> dict | None: u = await self.session.get(User, user_id) if u is None: @@ -440,6 +469,12 @@ class UserRepository: async def to_profile(self, user: dict) -> dict: """把用户记录裁剪成对外暴露的资料结构(不含任何密码字段)。""" profile = {field: user.get(field, "") for field in PROFILE_FIELDS} + # 小程序契约字段(C 端 /me 与登录响应共用):显示名 / 手机 / 绑定 / OPC 现状标签 / 关注主题 + 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["role"] = user.get("role", "opc_member") profile["sub_role"] = user.get("sub_role") profile["org_id"] = user.get("org_id") diff --git a/scripts/db/seed.py b/scripts/db/seed.py index b9ba843..e0b6c44 100644 --- a/scripts/db/seed.py +++ b/scripts/db/seed.py @@ -47,6 +47,52 @@ def _seed_training() -> None: conn.close() +async def _migrate_accounts_to_users(db) -> None: + """把培训端 accounts 存量并入平台 users(幂等:已存在账号跳过并保证身份)。 + + 迁移旧小程序账号 → users(wx_mini_openid/phone 关联),使微信/手机登录仍命中, + 且并入全局唯一账号体系。迁移/种子由用户执行(铁律),禁止运行态调用。 + """ + import secrets + import sqlite3 + from app.training import db as tdb + from app.api.routers.auth import _ensure_opc_identity + + conn = sqlite3.connect(tdb.DB_PATH) + conn.row_factory = sqlite3.Row + migrated = created = 0 + try: + for row in conn.execute("SELECT * FROM accounts"): + username = (row["username"] or "").strip() + wxid = (row["wxid"] or "").strip() + phone = (row["phone"] or "").strip() + if not username and not wxid and not phone: + continue + user = (await db.users.get_by_username(username)) if username else None + if user is None and phone: + user = await db.users.get_by_username(phone) + if user is None and wxid: + user = (await db.users.get_by_wx_mini_openid(wxid) + or await db.users.get_by_wx_openid(wxid)) + if user is None: + uname = (username or phone or f"wx_{wxid[:24]}") + user = await db.users.create( + username=uname, password=secrets.token_hex(16), + nickname=(row["name"] or uname), avatar=(row["avatar"] or ""), + phone=phone, wx_mini_openid=wxid or "", + wx_openid=(f"wx_{wxid[:24]}" if wxid else ""), + source="mini_program", + auth_type=("wx_openid" if wxid else ("phone" if phone else "unknown")), + role="opc_member", + ) + created += 1 + await _ensure_opc_identity(db, user["id"]) + migrated += 1 + log.info("accounts→users 迁移完成:共 %s 条,新建 %s 条", migrated, created) + finally: + conn.close() + + async def run() -> None: db = Database() try: @@ -54,6 +100,7 @@ async def run() -> None: await db.session.commit() log.info("平台种子完成:%s", config.DATABASE_URL) _seed_training() + await _migrate_accounts_to_users(db) except Exception as e: # noqa: BLE001 await db.session.rollback() log.error("种子失败:%s", e)