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
+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}