feat(users): 建号规则收紧与手机号绑定
- 管理端/园区端创建用户:账号系统随机生成不可指定,手机号必填且全局唯一
- 管理端用户管理支持绑定/更换手机号(PATCH /admin/users/{id}/phone)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
+9
-9
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user