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:
@@ -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
@@ -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}
|
||||
|
||||
@@ -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="")
|
||||
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user