diff --git a/app/api/routers/rbac_admin.py b/app/api/routers/rbac_admin.py index abb69b4..18e5beb 100644 --- a/app/api/routers/rbac_admin.py +++ b/app/api/routers/rbac_admin.py @@ -7,7 +7,7 @@ from pydantic import BaseModel, Field from ..dependencies import get_db from ..schemas.admin import SetUserRoleRequest, SetUserStatusRequest, UserClassificationRequest -from ..schemas.admin import UserCreateRequest, RolePermissionRequest, UpdateUserRequest +from ..schemas.admin import UserCreateRequest, RolePermissionRequest, UpdateUserRequest, BindPhoneRequest from ..schemas.admin import OpcCertReviewRequest, ParkAdmissionReviewRequest, ParkTransferReviewRequest from ...rbac import require_permission, require_roles, write_audit from ...infrastructure.repositories import Database @@ -38,7 +38,23 @@ async def create_user( await _sync_compute(compute_client.create_user(user["username"])) await _sync_compute(compute_client.issue_pat(user["username"])) await write_audit(db, action="user.create", resource="user", resource_id=user["id"], - detail=f"role={req.role}", user=actor, request=request) + detail=f"role={req.role} phone={req.phone}", user=actor, request=request) + return await db.users.to_profile(user) + + +@router.patch("/users/{uid}/phone", summary="为用户绑定/更换手机号") +async def bind_user_phone( + uid: str, + req: BindPhoneRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("action:user.manage")), +): + from ...services.user_admin_service import UserAdminService + + user = await UserAdminService(db).bind_phone(uid, req.phone) + await write_audit(db, action="user.bind_phone", resource="user", resource_id=uid, + detail=f"phone={req.phone}", user=actor, request=request) return await db.users.to_profile(user) diff --git a/app/api/schemas/admin.py b/app/api/schemas/admin.py index c4c3bbe..740328d 100644 --- a/app/api/schemas/admin.py +++ b/app/api/schemas/admin.py @@ -27,7 +27,8 @@ class UpdateUserRequest(BaseModel): region_id: str | None = None class UserCreateRequest(BaseModel): - username: str # 手机号 + """管理员建号:账号由系统随机生成(不可指定),手机号必填且全局唯一。""" + phone: str # 手机号(必填,11 位大陆手机号) password: str nickname: str = "" role: str = "opc_member" @@ -36,6 +37,11 @@ class UserCreateRequest(BaseModel): region_id: str | None = None +class BindPhoneRequest(BaseModel): + """为已有用户绑定/更换手机号。""" + phone: str + + class RolePermissionRequest(BaseModel): permissions: list[str] = [] diff --git a/app/park/routers.py b/app/park/routers.py index fee6dae..dfa4cc5 100644 --- a/app/park/routers.py +++ b/app/park/routers.py @@ -1268,7 +1268,7 @@ async def carrier_company_set_owner(cid: str, body: CompanyOwnerBody, user: dict # ==================== 园区端:用户管理(本园区,新建/编辑/列) ==================== class ParkUserCreateBody(BaseModel): - username: str + username: str = "" # 已废弃:账号由系统随机生成,字段仅为兼容旧前端保留 password: str = "" nickname: str = "" gender: str = "" @@ -1301,22 +1301,22 @@ async def park_users(db=Depends(_carrier_db), user: dict = Depends(_carrier_user return {"items": await db.users.list_by_park(t["id"])} -@router.post("/api/users", summary="新增本园区用户(全局唯一账号)") +@router.post("/api/users", summary="新增本园区用户(账号随机生成,手机号必填)") async def park_user_create(body: ParkUserCreateBody, db=Depends(_carrier_db), user: dict = Depends(_carrier_user)): + from ..services.user_admin_service import create_username, validate_phone + t = await _my_park(user) - uname = body.username.strip() - if not uname: - raise HTTPException(status_code=400, detail="账号必填") if not (body.nickname or "").strip(): raise HTTPException(status_code=400, detail="姓名必填") - if await db.users.get_by_username(uname): - raise HTTPException(status_code=400, detail="账号已存在") - if body.phone and await db.users.find_by_phone(body.phone): + # 手机号必填且全局唯一;账号(username)由系统随机生成,不可指定 + phone = validate_phone(body.phone or "") + if await db.users.find_by_phone(phone): raise HTTPException(status_code=400, detail="手机号已被使用") if not body.password: raise HTTPException(status_code=400, detail="请设置初始密码") + uname = await create_username(db) u = await db.users.create(uname, body.password, nickname=body.nickname, gender=body.gender, - birthday=body.birthday, phone=body.phone, email=body.email, company=body.company, + birthday=body.birthday, phone=phone, email=body.email, company=body.company, id_card=body.id_card, ethnicity=body.ethnicity, grad_school_major=body.grad_school_major, grad_time=body.grad_time, source="park", role="opc_member", affiliation="park", park_id=t["id"], park_name=t["name"], account_type="park_staff") diff --git a/app/services/user_admin_service.py b/app/services/user_admin_service.py index 828c34c..ea5c976 100644 --- a/app/services/user_admin_service.py +++ b/app/services/user_admin_service.py @@ -2,10 +2,33 @@ """业务层 · 用户管理服务(角色分配提权防护 + 建号联动默认身份)。""" from __future__ import annotations +import re +import secrets + from fastapi import HTTPException from ..infrastructure.repositories import Database +# 11 位大陆手机号 +PHONE_RE = re.compile(r"^1[3-9]\d{9}$") + + +def validate_phone(phone: str) -> str: + """校验并归一化手机号;不合法抛 400。""" + p = (phone or "").strip() + if not PHONE_RE.match(p): + raise HTTPException(status_code=400, detail="手机号格式不正确(需为 11 位大陆手机号)") + return p + + +async def create_username(db: Database) -> str: + """生成随机账号(u_<20hex>),全局唯一,账号不可由用户指定。""" + for _ in range(8): + uname = f"u_{secrets.token_hex(10)}" + if await db.users.get_by_username(uname) is None: + return uname + raise HTTPException(status_code=500, detail="账号生成失败,请重试") + class UserAdminService: """运营端账号/角色管理。""" @@ -24,19 +47,36 @@ class UserAdminService: raise HTTPException(status_code=403, detail="无权创建/分配特权角色(仅超级管理员)") async def create_user(self, req, actor: dict) -> dict: - """创建账号 + 自动创建默认端口身份(含提权防护)。""" - if not req.username.strip() or not req.password.strip(): - raise HTTPException(status_code=400, detail="账号与密码必填") - if await self.db.users.get_by_username(req.username) is not None: - raise HTTPException(status_code=400, detail="账号已存在") + """创建账号(系统随机生成)+ 自动创建默认端口身份(含提权防护)。 + + 规则:手机号必填且全局唯一;账号(username)随机生成,不可指定。 + """ + phone = validate_phone(getattr(req, "phone", "")) + if not req.password.strip(): + raise HTTPException(status_code=400, detail="密码必填") + if await self.db.users.find_by_phone(phone) is not None: + raise HTTPException(status_code=400, detail="手机号已被使用") await self._validate_role_assignment(actor, req.role, req.sub_role) + username = await create_username(self.db) user = await self.db.users.create( - req.username, req.password, nickname=req.nickname, + username, req.password, nickname=req.nickname, + phone=phone, role=req.role, sub_role=req.sub_role, org_id=req.org_id, region_id=req.region_id, source="admin", auth_type="admin", ) return user + async def bind_phone(self, user_id: str, phone: str) -> dict: + """为用户绑定/更换手机号(格式校验 + 全局唯一,排除自身)。""" + p = validate_phone(phone) + existing = await self.db.users.find_by_phone(p) + if existing is not None and existing["id"] != user_id: + raise HTTPException(status_code=400, detail="手机号已被其他用户使用") + u = await self.db.users.update_profile(user_id, {"phone": p}) + if u is None: + raise HTTPException(status_code=404, detail="用户不存在") + return u + async def set_user_role(self, actor: dict, user_id: str, role: str, sub_role: str, org_id: str, region_id: str) -> dict: """分配角色(含提权防护,同步主身份并失效旧令牌)。""" await self._validate_role_assignment(actor, role, sub_role)