From 879396b5327eb9981a7bc97a58cad1d6e4265734 Mon Sep 17 00:00:00 2001 From: Pine Date: Tue, 25 Aug 2026 16:21:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(user):=20=E5=85=A8=E5=B1=80=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=87=86=E7=A1=AE=E6=9D=A5=E6=BA=90=20+=20=E4=B8=B0?= =?UTF-8?q?=E5=AF=8C=E8=B7=A8=E7=AB=AF=E5=AD=97=E6=AE=B5(=E5=85=A8?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E5=85=BC=E5=AE=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users 增: wx_unionid/wx_mini_openid(微信绑定), source(来源枚举), auth_type, register_ip, last_login_ip/at, email, gender, birthday, compute_provisioned/username/quota/used_quota(算力镜像); alembic 0006 - 来源: register/phone-login/wx-login(读 X-Client 头,回退 body.source,再回退按 auth_type 推断)、admin 创建=admin、seed=seed - auth: 登录(签发令牌)记 last_login; profile 返回 source/auth_type/compute; email/gender/birthday 可编辑 - 对账 sync-users 回写 compute_* 镜像到平台用户 - _user_to_dict 含全部新字段; 迁移已应用, 导入/零依赖校验通过(dict 含 source/算力) --- alembic/versions/0006_user_source_fields.py | 43 ++++++++++++++ app/api/routers/auth.py | 58 ++++++++++++++++-- app/api/routers/rbac_operator.py | 9 +++ app/infrastructure/models.py | 18 +++++- app/infrastructure/repositories.py | 65 +++++++++++++++++++++ app/infrastructure/seed.py | 2 + app/services/user_admin_service.py | 1 + 7 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/0006_user_source_fields.py diff --git a/alembic/versions/0006_user_source_fields.py b/alembic/versions/0006_user_source_fields.py new file mode 100644 index 0000000..fd864c3 --- /dev/null +++ b/alembic/versions/0006_user_source_fields.py @@ -0,0 +1,43 @@ +"""user source/rich fields + +Revision ID: 0006_user_source_fields +Revises: 0005_park_settings +Create Date: 2026-08-25 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0006_user_source_fields" +down_revision = "0005_park_settings" +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: + _add("users", sa.Column("wx_unionid", sa.String, server_default="")) + _add("users", sa.Column("wx_mini_openid", sa.String, server_default="")) + _add("users", sa.Column("email", sa.String, server_default="")) + _add("users", sa.Column("gender", sa.String, server_default="")) + _add("users", sa.Column("birthday", sa.String, server_default="")) + _add("users", sa.Column("source", sa.String, server_default="")) + _add("users", sa.Column("auth_type", sa.String, server_default="")) + _add("users", sa.Column("register_ip", sa.String, server_default="")) + _add("users", sa.Column("last_login_ip", sa.String, server_default="")) + _add("users", sa.Column("last_login_at", sa.String, server_default="")) + _add("users", sa.Column("compute_provisioned", sa.Boolean, server_default="0")) + _add("users", sa.Column("compute_username", sa.String, server_default="")) + _add("users", sa.Column("compute_quota", sa.Integer, server_default="0")) + _add("users", sa.Column("compute_used_quota", sa.Integer, server_default="0")) + + +def downgrade() -> None: + pass diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 3867144..02fc377 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -36,6 +36,38 @@ from ...services import sms, wechat router = APIRouter(prefix="/auth", tags=["auth"]) +# 用户来源(X-Client 头,回退 body.source;未指认则按 auth_type 推断) +_SOURCE_BY_CLIENT = { + "miniprogram": "mini_program", "mini_program": "mini_program", "mp": "mini_program", + "web": "web", "browser": "web", + "desktop": "desktop", "app": "desktop", "tauri": "desktop", + "park": "park", "admin": "admin", "enterprise": "enterprise", + "carrier": "carrier", "government": "government", "provider": "provider", "operator": "operator", +} + + +def _client_source(request: Request, body_source: str = "", auth_type: str = "") -> str: + """确定用户来源:X-Client 头 > body.source > 按 auth_type 推断。""" + client = (request.headers.get("X-Client", "") or "").strip().lower() + if client in _SOURCE_BY_CLIENT: + return _SOURCE_BY_CLIENT[client] + if body_source in _SOURCE_BY_CLIENT.values(): + return body_source + if body_source: + return body_source + if auth_type == "wx_openid": + return "wx" + if auth_type == "phone": + return "phone" + return "web" + + +def _client_ip(request: Request) -> str: + xff = request.headers.get("X-Forwarded-For", "") + if xff: + return xff.split(",")[0].strip() + return request.client.host if (request.client and request.client.address) else "" + # 账号统一使用手机号作为登录账号(11 位,1 开头) _PHONE_RE = re.compile(r"^1\d{10}$") @@ -50,6 +82,9 @@ _PROFILE_FIELDS = ( "room", "avatar", "company_avatar", + "email", + "gender", + "birthday", ) @@ -89,6 +124,7 @@ async def _issue_token( token_user = dict(user) token_user.update(role=role, sub_role=sub_role, org_id=org_id, region_id=region_id) + await db.users.mark_login(user["id"]) # 记录最近登录时间(每次签发令牌=一次活动) return await db.tokens.create( token_user, permissions=perms, @@ -111,6 +147,12 @@ 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["source"] = user.get("source", "") + profile["auth_type"] = user.get("auth_type", "") + profile["company"] = user.get("company", "") + profile["compute_provisioned"] = bool(user.get("compute_provisioned", False)) + profile["compute_quota"] = user.get("compute_quota", 0) + profile["compute_used_quota"] = user.get("compute_used_quota", 0) profile["role"] = role profile["sub_role"] = sub_role profile["org_id"] = org_id @@ -177,7 +219,7 @@ async def select_identity( @router.post("/register", response_model=LoginResponse, summary="注册") -async def register(req: RegisterRequest, db: Database = Depends(get_db)): +async def register(req: RegisterRequest, request: Request, db: Database = Depends(get_db)): """注册唯一账户(演示端已存在 pine,故返回 403)。""" if not _is_phone(req.username): raise HTTPException( @@ -194,7 +236,11 @@ async def register(req: RegisterRequest, db: Database = Depends(get_db)): detail="Username and password are required", ) - user = await db.users.create(req.username, req.password) + user = await db.users.create( + req.username, req.password, + source=_client_source(request, getattr(req, "source", ""), "password"), + auth_type="password", register_ip=_client_ip(request), + ) identity = await db.identities.create( user["id"], port="opc", role="opc_member", sub_role="independent", name="独立OPC", @@ -372,7 +418,7 @@ async def send_code(req: SendCodeRequest, db: Database = Depends(get_db)): @router.post("/phone-login", response_model=LoginResponse, summary="手机号验证码登录") -async def phone_login(req: PhoneLoginRequest, db: Database = Depends(get_db)): +async def phone_login(req: PhoneLoginRequest, request: Request, db: Database = Depends(get_db)): """手机号 + 验证码登录:无账号则注册(建 opc_member 身份)。""" if not _is_phone(req.phone): raise HTTPException(status_code=400, detail="手机号需为 11 位(1 开头)") @@ -388,6 +434,8 @@ async def phone_login(req: PhoneLoginRequest, db: Database = Depends(get_db)): user = await db.users.create( req.phone, password=secrets.token_hex(16), phone=req.phone, role="opc_member", + source=_client_source(request, getattr(req, "source", ""), "phone"), + auth_type="phone", register_ip=_client_ip(request), ) if user.get("status") != "active": raise HTTPException(status_code=403, detail="账号已禁用") @@ -401,7 +449,7 @@ async def phone_login(req: PhoneLoginRequest, db: Database = Depends(get_db)): @router.post("/wx-login", response_model=LoginResponse, summary="微信登录") -async def wx_login(req: WxLoginRequest, db: Database = Depends(get_db)): +async def wx_login(req: WxLoginRequest, request: Request, db: Database = Depends(get_db)): """微信 code 登录:未配置 appid 时直通(code 当 openid),便于联调。""" if not config.AUTH_ENABLED: raise HTTPException(status_code=403, detail="认证未开启") @@ -415,6 +463,8 @@ async def wx_login(req: WxLoginRequest, db: Database = Depends(get_db)): 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), ) if user.get("status") != "active": raise HTTPException(status_code=403, detail="账号已禁用") diff --git a/app/api/routers/rbac_operator.py b/app/api/routers/rbac_operator.py index 8666ffc..9630632 100644 --- a/app/api/routers/rbac_operator.py +++ b/app/api/routers/rbac_operator.py @@ -239,6 +239,15 @@ async def sync_compute_users( pats += 1 except Exception: # noqa: BLE001 pass + # 回写算力引擎镜像(provisioned/quota/used)到平台用户,供直观展示 + try: + bal = await compute_client.user_balance(uname) + await db.users.set_compute_mirror( + u["id"], provisioned=True, username=uname, + quota=int(bal.get("quota", 0) or 0), used_quota=int(bal.get("used_quota", 0) or 0), + ) + except Exception: # noqa: BLE001 + pass # 对账幂等自明、且涉及对 db 只读 + 大量外部调用,不写审计(write_audit 的 commit 与该只读会话 # 事务交互会触发 PendingRollback/UNIQUE 冲突并污染会话)。仅返回统计。 return {"total": len(users), "synced": created, "pat_issued": pats} diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 9a85fc4..7390c25 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -36,14 +36,19 @@ class User(Base): password_hash: Mapped[str] = mapped_column(String, nullable=False) password_salt: Mapped[str] = mapped_column(String, nullable=False) # 统一登录:微信 openid(登录即建 opc_member 身份);phone 与 username(手机号)对齐 - wx_openid: Mapped[str] = mapped_column(String, default="", index=True) + wx_openid: Mapped[str] = mapped_column(String, default="", index=True) # 公众号/网页开放平台 + wx_unionid: Mapped[str] = mapped_column(String, default="", index=True) # 微信 unionid(跨应用统一) + wx_mini_openid: Mapped[str] = mapped_column(String, default="", index=True) # 小程序 openid(与网页不同) phone: Mapped[str] = mapped_column(String, default="") + email: Mapped[str] = mapped_column(String, default="") nickname: Mapped[str] = mapped_column(String, default="") account: Mapped[str] = mapped_column(String, default="") company: Mapped[str] = mapped_column(String, default="") room: Mapped[str] = mapped_column(String, default="") avatar: Mapped[str] = mapped_column(String, default="") company_avatar: Mapped[str] = mapped_column(String, default="") + gender: Mapped[str] = mapped_column(String, default="") # male|female|unknown + birthday: Mapped[str] = mapped_column(String, default="") # YYYY-MM-DD # RBAC role: Mapped[str] = mapped_column(String, default="opc_member") # 业务角色 sub_role: Mapped[str] = mapped_column(String, nullable=True) # 政务级/运营方内部 @@ -51,6 +56,17 @@ class User(Base): region_id: Mapped[str | None] = mapped_column(ForeignKey("regions.id"), nullable=True) status: Mapped[str] = mapped_column(String, default="active") # active | disabled token_version: Mapped[int] = mapped_column(Integer, default=0) # revoke-all 递增 + # ── 来源与登录跟踪(全局用户统一,记录准确来源) ── + source: Mapped[str] = mapped_column(String, default="") # mini_program|web|desktop|park|admin|seed|wx|phone|unknown + auth_type: Mapped[str] = mapped_column(String, default="") # password|phone|wx_openid|admin + register_ip: Mapped[str] = mapped_column(String, default="") + last_login_ip: Mapped[str] = mapped_column(String, default="") + last_login_at: Mapped[str] = mapped_column(String, default="") + # ── 算力(引擎用户镜像,全局一致) ── + compute_provisioned: Mapped[bool] = mapped_column(Boolean, default=False) # 是否已在算力引擎建号 + compute_username: Mapped[str] = mapped_column(String, default="") # 引擎用户名(= username) + compute_quota: Mapped[int] = mapped_column(Integer, default=0) # 缓存引擎可用额度 + compute_used_quota: Mapped[int] = mapped_column(Integer, default=0) # 缓存引擎已用额度 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 5d601b8..fcb4c11 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -152,7 +152,21 @@ def _user_to_dict(u: User) -> dict: "status": u.status, "token_version": u.token_version, "wx_openid": u.wx_openid, + "wx_unionid": u.wx_unionid, + "wx_mini_openid": u.wx_mini_openid, "phone": u.phone, + "email": u.email, + "gender": u.gender, + "birthday": u.birthday, + "source": u.source, + "auth_type": u.auth_type, + "register_ip": u.register_ip, + "last_login_ip": u.last_login_ip, + "last_login_at": u.last_login_at, + "compute_provisioned": bool(u.compute_provisioned), + "compute_username": u.compute_username, + "compute_quota": u.compute_quota, + "compute_used_quota": u.compute_used_quota, "created_at": u.created_at, "updated_at": u.updated_at, } @@ -206,7 +220,19 @@ class UserRepository: org_id: str | None = None, region_id: str | None = None, wx_openid: str = "", + wx_unionid: str = "", + wx_mini_openid: str = "", phone: str = "", + email: str = "", + gender: str = "", + birthday: str = "", + source: str = "unknown", + auth_type: str = "", + register_ip: str = "", + compute_provisioned: bool = False, + compute_username: str = "", + compute_quota: int = 0, + compute_used_quota: int = 0, ) -> dict: digest, salt = hash_password(password) now = utcnow_iso() @@ -228,7 +254,21 @@ class UserRepository: status="active", token_version=0, wx_openid=wx_openid, + wx_unionid=wx_unionid, + wx_mini_openid=wx_mini_openid, phone=phone, + email=email, + gender=gender, + birthday=birthday, + source=source, + auth_type=auth_type, + register_ip=register_ip, + last_login_ip=register_ip, + last_login_at=now, + compute_provisioned=compute_provisioned, + compute_username=compute_username, + compute_quota=compute_quota, + compute_used_quota=compute_used_quota, created_at=now, updated_at=now, ) @@ -236,6 +276,31 @@ class UserRepository: await self.session.commit() return _user_to_dict(u) + async def mark_login(self, user_id: str, ip: str = "") -> None: + """记录最近登录时间与 IP。""" + u = await self.session.get(User, user_id) + if u is None: + return + u.last_login_at = utcnow_iso() + if ip: + u.last_login_ip = ip + u.updated_at = utcnow_iso() + await self.session.commit() + + async def set_compute_mirror(self, user_id: str, *, provisioned: bool, username: str, + quota: int, used_quota: int) -> None: + """回写算力引擎镜像(全局用户一致)。""" + u = await self.session.get(User, user_id) + if u is None: + return + u.compute_provisioned = provisioned + if username: + u.compute_username = username + u.compute_quota = quota + u.compute_used_quota = used_quota + u.updated_at = utcnow_iso() + await self.session.commit() + async def get_by_wx_openid(self, wx_openid: str) -> dict | None: u = await self.session.scalar( select(User).where(User.wx_openid == wx_openid.strip()), diff --git a/app/infrastructure/seed.py b/app/infrastructure/seed.py index 0bdc2df..df40097 100644 --- a/app/infrastructure/seed.py +++ b/app/infrastructure/seed.py @@ -328,6 +328,7 @@ async def seed_data(session: AsyncSession) -> None: password_hash=digest, password_salt=salt, role=role, sub_role=sub_role, org_id=org_id, region_id=region_id, status="active", token_version=0, created_at=now, updated_at=now, + source="seed", auth_type="admin", ) ) # 为每个用户播种默认 + QA 智能体(幂等按 (id,user_id,port=null) 防重) @@ -989,6 +990,7 @@ async def _ensure_extra_demo_users(session: AsyncSession, now: str) -> None: password_hash=digest, password_salt=salt, role=role, sub_role=sub_role, org_id=org_id, region_id=region_id, status="active", token_version=0, created_at=now, updated_at=now, + source="seed", auth_type="admin", ) ) for seed in AGENT_SEED: diff --git a/app/services/user_admin_service.py b/app/services/user_admin_service.py index 0be01d4..2cd7d70 100644 --- a/app/services/user_admin_service.py +++ b/app/services/user_admin_service.py @@ -41,6 +41,7 @@ class UserAdminService: user = await self.db.users.create( req.username, req.password, nickname=req.nickname, role=req.role, sub_role=req.sub_role, org_id=req.org_id, region_id=req.region_id, + source="admin", auth_type="admin", ) await self.db.identities.create( user["id"], port=_PORT_FOR_ROLE.get(req.role, "opc"), role=req.role,