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
+32
View File
@@ -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
+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):
+3
View File
@@ -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="")
+35
View File
@@ -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")
+47
View File
@@ -47,6 +47,52 @@ def _seed_training() -> None:
conn.close()
async def _migrate_accounts_to_users(db) -> None:
"""把培训端 accounts 存量并入平台 users(幂等:已存在账号跳过并保证身份)。
迁移旧小程序账号 → userswx_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)