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