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:
@@ -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
@@ -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="账号已禁用")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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="")
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
Reference in New Issue
Block a user