feat(user): 全局用户准确来源 + 丰富跨端字段(全端口兼容)

- 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/算力)
This commit is contained in:
Pine
2026-08-25 16:21:20 +08:00
parent ad14f9ccce
commit 879396b532
7 changed files with 191 additions and 5 deletions
@@ -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
+54 -4
View File
@@ -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="账号已禁用")
+9
View File
@@ -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}
+17 -1
View File
@@ -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="")
+65
View File
@@ -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()),
+2
View File
@@ -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:
+1
View File
@@ -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,