Files
server-core/app/api/routers/rbac_admin.py
T
Pine 6bb988cc3e refactor: 角色组织账号模型重构 - 阶段一
1. capabilities 写入 JWT,避免每次请求查库
2. 移除 service 账号类型,非 operator/carrier 统一归为 opc_member
3. 前端统一基于 capabilities 判断权限
4. 清理登录接口死代码,恢复账号密码登录
5. 移除 enterprise/provider/government/investor/developer 角色
6. 清理 seed.py 中多余角色和权限定义
7. 重写 rbac_portals.py 只保留 carrier 路由
2026-09-03 00:35:40 +08:00

1083 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""运营方管理端点(证明 RBAC 角色/权限/审计)。"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from ..dependencies import get_db
from ..schemas.admin import SetUserRoleRequest, SetUserStatusRequest, UserClassificationRequest
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
from ...services import compute_client
router = APIRouter(prefix="/admin", tags=["admin"])
async def _sync_compute(coro) -> None:
"""算力引擎用户同步为附加能力:失败不阻断平台用户操作(引擎未配置/不可达时静默)。"""
try:
await coro
except Exception: # noqa: BLE001
pass
@router.post("/users", summary="新增账号(按类型/角色)")
async def create_user(
req: UserCreateRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.assign_role")),
):
from ...services.user_admin_service import UserAdminService
user = await UserAdminService(db).create_user(req, actor)
# 平台用户创建 → 引擎同步建号 + 签发 PAT(算力引擎用户与平台一致)
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} 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)
def _port_for_role(role: str) -> str:
return {
"opc_member": "opc", "carrier": "carrier", "operator": "operator",
}.get(role, "opc")
@router.get("/users", summary="用户列表(运营方)")
async def list_users(
request: Request,
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
from ...infrastructure.oss import resolve_url
items = await db.users.list()
for u in items: # 出口规范:头像对象路径统一生成 CDN 直链
u["avatar"] = resolve_url(u.get("avatar", ""))
u["company_avatar"] = resolve_url(u.get("company_avatar", ""))
# 邀请归因:补充邀请人昵称和邀请数量
inviter_id = u.get("inviter_id", "")
if inviter_id:
inviter = await db.users.get_by_id(inviter_id)
u["inviter_name"] = inviter.get("nickname", "") or inviter.get("username", "") if inviter else ""
else:
u["inviter_name"] = ""
invitees = await db.users.get_invitees(u["id"])
u["invite_count"] = len(invitees)
return items
@router.post("/users/{user_id}/role", summary="分配角色")
async def set_user_role(
user_id: str,
req: SetUserRoleRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.assign_role")),
):
from ...services.user_admin_service import UserAdminService
if await db.users.get_by_id(user_id) is None:
raise HTTPException(status_code=404, detail="User not found")
updated = await UserAdminService(db).set_user_role(
actor, user_id, req.role, req.sub_role, req.org_id, req.region_id,
)
await write_audit(
db, action="role.assign", resource="user", resource_id=user_id,
detail=f"{actor['username']} -> role={req.role} sub={req.sub_role}",
user=actor, request=request,
)
return {"ok": True, "user": await db.users.to_profile(updated)}
@router.post("/users/{user_id}/classification", summary="设置用户认证状态/所属/账号类型")
async def set_user_classification(
user_id: str,
req: UserClassificationRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
# 铁律:任何端口不得手动修改用户「所属园区」。affiliation/park_id/park_name 仅经
# 入驻申请 / 转园申请(审核通过)回填,此处剔除,只允许改 认证状态/账号类型。
fields = {k: v for k, v in req.model_dump(exclude_none=True).items()
if k in ("certification_status", "certification_time", "account_type")}
updated = await db.users.set_classification(user_id, **fields)
if updated is None:
raise HTTPException(status_code=404, detail="User not found")
await write_audit(db, action="user.classification", resource="user", resource_id=user_id,
detail=str(fields), user=actor, request=request)
return {"ok": True, "user": updated}
@router.post("/users/{user_id}/status", summary="禁用/启用用户")
async def set_user_status(
user_id: str,
req: SetUserStatusRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.disable")),
):
user = await db.users.get_by_id(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
updated = await db.users.set_status(user_id, req.status)
# 平台用户启用/禁用 → 引擎镜像同步
await _sync_compute(compute_client.sync_user_enabled(
user.get("username"), req.status == "active",
))
await write_audit(
db, action="user.disable", resource="user", resource_id=user_id,
detail=f"{actor['username']} -> status={req.status}",
user=actor, request=request,
)
return {"ok": True, "user": await db.users.to_profile(updated)}
@router.put("/users/{user_id}", summary="修改用户(资料/角色)")
async def update_user(
user_id: str,
req: UpdateUserRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
user = await db.users.get_by_id(user_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
profile_fields = {k: v for k, v in req.model_dump(exclude_none=True).items() if k in ("nickname",)}
if profile_fields:
user = await db.users.update_profile(user_id, profile_fields) or user
if req.role or req.sub_role is not None:
user = await db.users.set_role(user_id, req.role or user.get("role"), req.sub_role,
req.org_id or user.get("org_id"), req.region_id or user.get("region_id"))
await write_audit(db, action="user.update", resource="user", resource_id=user_id,
detail=str(req.model_dump(exclude_none=True)), user=actor, request=request)
return {"ok": True, "user": await db.users.to_profile(user)}
@router.delete("/users/{user_id}", summary="删除用户")
async def delete_user(
user_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
user = await db.users.get_by_id(user_id)
ok = await db.users.delete(user_id)
if not ok:
raise HTTPException(status_code=404, detail="User not found")
# 平台用户删除 → 引擎镜像同步删除
if user:
await _sync_compute(compute_client.sync_delete_user(user.get("username")))
await write_audit(db, action="user.delete", resource="user", resource_id=user_id,
detail="deleted", user=actor, request=request)
return {"ok": True, "user_id": user_id}
@router.get("/roles", summary="角色列表")
async def list_roles(
db: Database = Depends(get_db),
_user: dict = Depends(require_permission("menu:admin_role_mgmt")),
):
return await db.roles.list_roles()
@router.get("/permissions", summary="权限列表")
async def list_permissions(
db: Database = Depends(get_db),
_user: dict = Depends(require_permission("menu:admin_role_mgmt")),
):
return await db.roles.list_permissions()
@router.post("/roles/{role_id}/permissions", summary="配置角色权限")
async def set_role_permissions(
role_id: str,
req: RolePermissionRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:role.grant_perm")),
):
if not await db.roles.role_exists(role_id):
raise HTTPException(status_code=404, detail="Role not found")
perms = await db.roles.set_role_permissions(role_id, req.permissions)
await write_audit(db, action="role.grant_perm", resource="role", resource_id=role_id,
detail=f"permissions={len(req.permissions)}", user=actor, request=request)
return {"ok": True, "role_id": role_id, "permissions": perms}
@router.get("/audit-logs", summary="审计日志")
async def list_audit_logs(
limit: int = 100,
offset: int = 0,
db: Database = Depends(get_db),
_user: dict = Depends(require_permission("action:audit.view")),
):
return await db.audit.list(limit=min(limit, 500), offset=offset)
async def _apply_admission_user(db, adm, status):
"""入园/转园审核通过后回填 users 的园区归属。"""
if status not in ("approved", "certified"):
return
uid = (adm or {}).get("user_id")
if not uid:
return
await db.users.set_classification(uid, affiliation="park",
park_id=(adm or {}).get("tenant_id", ""),
park_name=(adm or {}).get("tenant_name", ""))
# ==================== OPC 认证 / 入园 / 转园 审核(平台 operator 兜底) ====================
@router.get("/opc-certifications", summary="OPC 认证申请列表(平台)")
async def list_opc_certifications(
status: str = "",
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
return {"items": await db.opc_certifications.list(status or None)}
@router.post("/opc-certifications/{cert_id}/review", summary="审核 OPC 认证申请")
async def review_opc_certification(
cert_id: str,
req: OpcCertReviewRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
cert = await db.opc_certifications.get(cert_id)
if cert is None:
raise HTTPException(status_code=404, detail="认证申请不存在")
if req.status not in ("certified", "rejected"):
raise HTTPException(status_code=400, detail="状态仅支持 certified/rejected")
updated = await db.opc_certifications.set_status(cert_id, req.status,
reviewer=actor.get("id", ""), comment=req.comment)
if cert.get("user_id"):
u = await db.users.get_by_id(cert["user_id"])
await db.users.set_classification(cert["user_id"],
certification_status=req.status, affiliation=(u or {}).get("affiliation") or "independent")
await write_audit(db, action="opc.cert_review", resource="opc_certification",
resource_id=cert_id, detail=f"status={req.status}", user=actor, request=request)
return {"ok": True, "certification": updated}
@router.get("/park-admissions", summary="园区入驻申请列表(平台)")
async def list_park_admissions(
status: str = "",
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
return {"items": await db.park_admissions.list(status or None)}
@router.post("/park-admissions/{aid}/review", summary="园区入驻申请审核(平台兜底)")
async def review_park_admission(
aid: str,
req: ParkAdmissionReviewRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
adm = await db.park_admissions.get(aid)
if adm is None:
raise HTTPException(status_code=404, detail="入驻申请不存在")
if req.status not in ("approved", "rejected", "reviewing"):
raise HTTPException(status_code=400, detail="状态仅支持 approved/rejected/reviewing")
updated = await db.park_admissions.set_status(aid, req.status,
reviewer=actor.get("id", ""), comment=req.comment)
await _apply_admission_user(db, adm, req.status)
await write_audit(db, action="opc.admission_review", resource="park_admission",
resource_id=aid, detail=f"status={req.status}", user=actor, request=request)
return {"ok": True, "admission": updated}
@router.get("/park-transfers", summary="OPC 转园申请列表(平台)")
async def list_park_transfers(
status: str = "",
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
return {"items": await db.park_transfers.list(status or None)}
@router.post("/park-transfers/{tid}/review", summary="转园申请审核(平台兜底终审,两级状态机)")
async def review_park_transfer(
tid: str,
req: ParkTransferReviewRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...domain import park_transfer as pt_rules
from ...services import membership_service
tr = await db.park_transfers.get(tid)
if tr is None:
raise HTTPException(status_code=404, detail="转园申请不存在")
if req.status not in ("approved", "rejected", "reviewing"):
raise HTTPException(status_code=400, detail="状态仅支持 approved/rejected/reviewing")
step = pt_rules.review(tr.get("stage", "pending_from"), side="operator", decision=req.status)
updated = await db.park_transfers.set_stage(
tid, stage=step["stage"], from_status=step["from_status"], to_status=step["to_status"],
operator_reviewer_id=actor.get("id", ""), comment=req.comment)
if step["stage"] == "approved":
# 终审通过 → 执行迁移(唯一写归属路径)
if tr.get("target_kind") == "company" and tr.get("company_id"):
await membership_service.transfer_company_park(
db, tr["company_id"], tr.get("from_park_id", ""), tr.get("to_park_id", ""),
operator_id=actor.get("id", ""))
elif tr.get("user_id"):
await membership_service.transfer_user_park(
db, tr["user_id"], tr.get("from_park_id", ""), tr.get("to_park_id", ""),
operator_id=actor.get("id", ""))
await write_audit(db, action="opc.park_transfer_review", resource="park_transfer",
resource_id=tid, detail=f"status={req.status} stage={step['stage']}", user=actor, request=request)
return {"ok": True, "transfer": updated}
# ==================== 平台园区管理(operator;复用 park 数据层,不依赖 app.state.db ====================
class AdminBindDeviceBody(BaseModel):
tenant_id: str
name: str = ""
role: str = "main"
location: str = ""
class AdminCreateTenantBody(BaseModel):
name: str
intro: list[str] = ["", ""]
username: str = ""
password: str = ""
class AdminTenantStatusBody(BaseModel):
status: str # active | disabled
def _park_tenant_public(t: dict) -> dict:
"""脱敏园区:剔除 auth(username/salt/password_hash),仅留管理用字段。"""
return {k: t.get(k) for k in ("id", "name", "intro", "admin_username", "status")}
@router.get("/park/screens", summary="全部屏幕(含未绑定+归属园区名,平台)")
async def admin_park_screens(
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
from app.park import tenants as tnt
return {"items": await tnt.list_all_screens()}
@router.get("/park/tenants", summary="园区列表(脱敏,平台)")
async def admin_park_tenants(
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
from app.park import tenants as tnt
rows = await tnt.list_tenants()
return {"items": [_park_tenant_public(t) for t in rows]}
@router.post("/park/tenants", summary="平台创建园区")
async def admin_park_create_tenant(
body: AdminCreateTenantBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
t = await tnt.create_tenant(body.name, body.intro, body.username, body.password)
await write_audit(db, action="park.create", resource="park_tenant",
resource_id=(t or {}).get("id", ""), detail=body.name, user=actor, request=request)
return {"ok": True, "tenant": _park_tenant_public(t or {})}
@router.delete("/park/tenants/{tid}", summary="平台删除园区(级联删屏)")
async def admin_park_delete_tenant(
tid: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
ok = await tnt.delete_tenant(tid)
if not ok:
raise HTTPException(status_code=404, detail="园区不存在")
await write_audit(db, action="park.delete", resource="park_tenant",
resource_id=tid, detail=tid, user=actor, request=request)
return {"ok": True}
@router.post("/park/tenants/{tid}/status", summary="平台停用/启用园区")
async def admin_park_tenant_status(
tid: str,
body: AdminTenantStatusBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
if body.status not in ("active", "disabled"):
raise HTTPException(status_code=400, detail="无效状态")
ok = await tnt.set_status(tid, body.status)
if not ok:
raise HTTPException(status_code=404, detail="园区不存在")
await write_audit(db, action="park.status", resource="park_tenant",
resource_id=tid, detail=body.status, user=actor, request=request)
return {"ok": True, "status": body.status}
@router.post("/park/devices/{device_id}/bind", summary="平台手动绑定屏幕到园区")
async def admin_park_bind_device(
device_id: str,
body: AdminBindDeviceBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
tenant = await tnt.get_tenant(body.tenant_id)
if tenant is None:
raise HTTPException(status_code=404, detail="目标园区不存在")
dev = await tnt.get_device_by_id(device_id)
if dev is None:
raise HTTPException(status_code=404, detail="屏幕不存在")
if dev.get("status") == "bound" and dev.get("tenant_id") and dev.get("tenant_id") != body.tenant_id:
raise HTTPException(status_code=400, detail="该屏幕已绑定到其它园区,请先解绑")
updated = await tnt.bind_device(body.tenant_id, device_id, body.name, body.role, body.location)
await write_audit(db, action="park.bind_screen", resource="park_screen",
resource_id=device_id, detail=f"tid={body.tenant_id}", user=actor, request=request)
return {"ok": True, "screen": updated}
@router.post("/park/devices/{device_id}/unbind", summary="平台解绑屏幕(从园区解绑,回未绑定)")
async def admin_park_unbind_device(
device_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
dev = await tnt.get_device_by_id(device_id)
if dev is None:
raise HTTPException(status_code=404, detail="屏幕不存在")
if dev.get("status") != "bound":
raise HTTPException(status_code=400, detail="该屏幕当前未绑定,无需解绑")
ok = await tnt.unbind_device(device_id)
if not ok:
raise HTTPException(status_code=500, detail="解绑失败")
await write_audit(db, action="park.unbind_screen", resource="park_screen",
resource_id=device_id, detail=f"from tid={dev.get('tenant_id')}", user=actor, request=request)
return {"ok": True}
class AdminCompanyComputeBody(BaseModel):
discount: int | None = Field(default=None, ge=0, le=100)
quota_add: int = Field(default=0, ge=0)
@router.get("/park/tenants/{tid}/companies", summary="园区企业列表(含成员数/算力,平台)")
async def admin_park_tenant_companies(
tid: str,
db: Database = Depends(get_db),
_role: dict = Depends(require_roles("operator")),
_perm: dict = Depends(require_permission("menu:admin_user_mgmt")),
):
from app.park import tenants as tnt
return {"items": await tnt.list_companies(tid)}
@router.put("/park/tenants/{tid}/companies/{cid}/compute", summary="设置企业算力(只增不减,平台兜底)")
async def admin_park_tenant_company_compute(
tid: str,
cid: str,
body: AdminCompanyComputeBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
c = await tnt.get_company(cid)
if c is None or c.get("tenant_id") != tid:
raise HTTPException(status_code=404, detail="企业不存在")
try:
updated = await tnt.set_company_compute(cid, discount=body.discount, quota_add=body.quota_add)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await tnt.sync_company_engine(cid)
if body.quota_add > 0:
from ..services import compute_client
for m in await tnt.company_members(cid):
try:
await compute_client.grant_user_quota_by_username(m["username"], body.quota_add)
except Exception: # noqa: BLE001
continue
await write_audit(db, action="park.company_compute", resource="park_company",
resource_id=cid, detail=f"discount={body.discount} quota_add={body.quota_add}", user=actor, request=request)
return {"ok": True, "company": updated, "quota_add": body.quota_add}
# ===========================================================================
# 用户/园区/企业归属管理(多对多成员制)
# 铁律:任何端口不允许直接调整用户/企业所属园区,必须走转园申请;
# users.affiliation/park_id 等展示缓存由 services.membership_service.sync_user_affiliation 刷新。
# ===========================================================================
class MembershipBody(BaseModel):
park_id: str = ""
company_id: str = ""
is_admin: bool = False
member_type: str = "staff"
class AdminUserCreateBody(BaseModel):
"""创建/绑定用户:mode=existing 按关键字搜索绑定已有账号;mode=new 新建(手机号必填唯一)。"""
mode: str = "new"
keyword: str = ""
user_id: str = ""
phone: str = ""
password: str = ""
nickname: str = ""
role: str = "opc_member"
class AdminParkCreateBody(BaseModel):
name: str
intro: list[str] = []
class AdminCompanyCreateBody(BaseModel):
name: str
company_kind: str = "park_entered" # park_entered 园区入驻 | independent 独立
tenant_id: str = ""
industry: str = ""
bio: str = ""
zone: str = ""
room: str = ""
founder: str = ""
owner_user_id: str = ""
class AdminCompanyParkBody(BaseModel):
tenant_id: str # 目标园区(企业绑定到园区 / 独立企业转入驻)
class AdminCompanyAdminBody(BaseModel):
user_id: str
class AdminTransferProposeBody(BaseModel):
target_kind: str = "user" # user | company
user_id: str = ""
company_id: str = ""
from_park_id: str
to_park_id: str
reason: str = ""
@router.get("/users/search", summary="搜索平台用户(用户名/手机号/昵称模糊,供绑定已有账号)")
async def admin_search_users(
keyword: str = "",
db: Database = Depends(get_db),
_a: dict = Depends(require_roles("operator")),
):
return {"items": await db.users.search(keyword)}
@router.get("/users/{uid}/memberships", summary="用户归属总览(园区多绑 + 企业多绑 + 类型派生)")
async def admin_user_memberships(
uid: str,
db: Database = Depends(get_db),
_a: dict = Depends(require_roles("operator")),
):
from ...services import membership_service
return await membership_service.membership_overview(db, uid)
@router.post("/users/{uid}/park-memberships", summary="绑定用户到园区(多绑,幂等;不改园区归属须走转园)")
async def admin_add_park_membership(
uid: str,
body: MembershipBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...services.membership_service import sync_user_affiliation
if await db.users.get_by_id(uid) is None:
raise HTTPException(status_code=404, detail="用户不存在")
if not body.park_id:
raise HTTPException(status_code=400, detail="park_id 必填")
await db.park_members.add(uid, body.park_id, member_type=body.member_type,
created_by=actor.get("id", ""))
await sync_user_affiliation(db, uid)
await write_audit(db, action="membership.park_add", resource="park_member",
resource_id=uid, detail=f"park={body.park_id}", user=actor, request=request)
return {"ok": True}
@router.delete("/users/{uid}/park-memberships/{park_id}", summary="解绑用户的园区成员关系(不影响其他园区)")
async def admin_remove_park_membership(
uid: str,
park_id: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...services.membership_service import sync_user_affiliation
if not await db.park_members.remove(uid, park_id):
raise HTTPException(status_code=404, detail="成员关系不存在")
await sync_user_affiliation(db, uid)
await write_audit(db, action="membership.park_remove", resource="park_member",
resource_id=uid, detail=f"park={park_id}", user=actor, request=request)
return {"ok": True}
@router.post("/users/{uid}/company-memberships", summary="绑定用户到企业(多绑;is_admin=绑定为企业管理员)")
async def admin_add_company_membership(
uid: str,
body: MembershipBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...services.membership_service import sync_user_affiliation
if await db.users.get_by_id(uid) is None:
raise HTTPException(status_code=404, detail="用户不存在")
if not body.company_id:
raise HTTPException(status_code=400, detail="company_id 必填")
from ...infrastructure.models import ParkCompany
from sqlalchemy import select
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == body.company_id))).scalars().first()
if comp is None:
raise HTTPException(status_code=404, detail="企业不存在")
await db.company_members.add(uid, body.company_id, is_admin=body.is_admin,
created_by=actor.get("id", ""))
if body.is_admin:
# 企业管理员是能力标记(is_admin),不是身份——用户角色保持不变(OPC 等)
comp.owner_user_id = uid
await db.session.commit()
await sync_user_affiliation(db, uid)
await write_audit(db, action="membership.company_add", resource="company_member",
resource_id=uid, detail=f"company={body.company_id} admin={body.is_admin}",
user=actor, request=request)
return {"ok": True}
@router.delete("/users/{uid}/company-memberships/{cid}", summary="解绑用户的企业成员关系")
async def admin_remove_company_membership(
uid: str,
cid: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...services.membership_service import sync_user_affiliation
if not await db.company_members.remove(uid, cid):
raise HTTPException(status_code=404, detail="成员关系不存在")
await sync_user_affiliation(db, uid)
await write_audit(db, action="membership.company_remove", resource="company_member",
resource_id=uid, detail=f"company={cid}", user=actor, request=request)
return {"ok": True}
@router.post("/parks", summary="创建园区(不注入演示数据)")
async def admin_create_park(
body: AdminParkCreateBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
if not body.name.strip():
raise HTTPException(status_code=400, detail="园区名称必填")
t = await tnt.create_tenant(body.name.strip(), body.intro)
await write_audit(db, action="park.create", resource="park_tenant",
resource_id=t.get("id", ""), detail=body.name, user=actor, request=request)
return {"ok": True, "tenant": _park_tenant_public(t)}
class AdminParkAdminBody(BaseModel):
user_id: str = "" # 目标用户(或 keyword 搜索命中)
keyword: str = ""
@router.post("/parks/{tid}/admin", summary="为园区绑定管理员(用户获得 carrier 角色 + 园区 admin 成员)")
async def admin_bind_park_admin(
tid: str,
body: AdminParkAdminBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
from ...services.membership_service import sync_user_affiliation
tenant = await tnt.get_tenant(tid)
if tenant is None:
raise HTTPException(status_code=404, detail="园区不存在")
user = None
if body.user_id:
user = await db.users.get_by_id(body.user_id)
elif body.keyword.strip():
hits = await db.users.search(body.keyword, limit=1)
user = hits[0] if hits else None
if user is None:
raise HTTPException(status_code=404, detail="用户不存在(可按用户名/手机号搜索)")
await tnt.bind_admin(tid, user["username"], operator_user_id=user["id"])
await db.park_members.add(user["id"], tid, member_type="admin", created_by=actor.get("id", ""))
# 叠加制:园区管理能力来自绑定关系,不改写用户基础身份(OPC 等)
await sync_user_affiliation(db, user["id"])
await write_audit(db, action="park.bind_admin", resource="park_tenant",
resource_id=tid, detail=f"user={user['username']}", user=actor, request=request)
return {"ok": True, "tenant_id": tid, "admin_user_id": user["id"], "admin_username": user["username"]}
@router.delete("/parks/{tid}/admin", summary="解绑园区管理员")
async def admin_unbind_park_admin(
tid: str,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
tenant = await tnt.get_tenant(tid)
demote = (tenant or {}).get("operator_user_id", "")
ok = await tnt.unbind_admin(tid, demote_user_id=demote)
await write_audit(db, action="park.unbind_admin", resource="park_tenant",
resource_id=tid, user=actor, request=request)
return {"ok": ok}
@router.get("/companies", summary="企业列表(?kind=park_entered|independent&tenant_id=")
async def admin_list_companies(
kind: str = "",
tenant_id: str = "",
db: Database = Depends(get_db),
_a: dict = Depends(require_roles("operator")),
):
from ...infrastructure.models import ParkCompany
from sqlalchemy import select
q = select(ParkCompany).order_by(ParkCompany.created_at.desc())
if kind:
q = q.where(ParkCompany.company_kind == kind)
if tenant_id:
q = q.where(ParkCompany.tenant_id == tenant_id)
rows = (await db.session.execute(q)).scalars().all()
from app.park.tenants import _co
items = []
for r in rows:
d = _co(r)
d["member_count"] = len(await db.company_members.list_by_company(r.id))
items.append(d)
return {"items": items}
@router.post("/companies", summary="创建企业(园区入驻/独立;可指定企业管理员)")
async def admin_create_company(
body: AdminCompanyCreateBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from app.park import tenants as tnt
kind = body.company_kind if body.company_kind in ("park_entered", "independent") else "park_entered"
if kind == "park_entered" and not body.tenant_id:
raise HTTPException(status_code=400, detail="园区入驻企业必须指定园区")
if kind == "independent" and body.tenant_id:
raise HTTPException(status_code=400, detail="独立企业不归属园区(先建独立企业,再经绑定接口入驻)")
comp = await tnt.create_company(body.tenant_id, {
"name": body.name, "company_kind": kind, "industry": body.industry, "bio": body.bio,
"zone": body.zone, "room": body.room, "founder": body.founder, "status": "active",
"owner_user_id": body.owner_user_id,
})
if body.owner_user_id:
# 叠加制:企业管理员=能力标记(is_admin),不改写用户基础身份
await tnt.add_company_member(comp["id"], body.owner_user_id, is_admin=True,
created_by=actor.get("id", ""))
await write_audit(db, action="company.create", resource="park_company",
resource_id=comp["id"], detail=f"kind={kind} name={body.name}",
user=actor, request=request)
return {"ok": True, "company": comp}
@router.post("/companies/{cid}/park", summary="企业绑定到园区(独立→入驻一次性动作;此后变更须转园申请)")
async def admin_bind_company_park(
cid: str,
body: AdminCompanyParkBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...infrastructure.models import ParkCompany
from sqlalchemy import select
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == cid))).scalars().first()
if comp is None:
raise HTTPException(status_code=404, detail="企业不存在")
from app.park import tenants as tnt
if await tnt.get_tenant(body.tenant_id) is None:
raise HTTPException(status_code=404, detail="目标园区不存在")
comp.tenant_id = body.tenant_id
comp.company_kind = "park_entered"
await db.session.commit()
await write_audit(db, action="company.bind_park", resource="park_company",
resource_id=cid, detail=f"tenant={body.tenant_id}", user=actor, request=request)
return {"ok": True, "company_id": cid, "tenant_id": body.tenant_id}
@router.post("/companies/{cid}/admin", summary="为企业绑定管理员(enterprise 角色 + 成员 is_admin")
async def admin_bind_company_admin(
cid: str,
body: AdminCompanyAdminBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...services.membership_service import sync_user_affiliation
from ...infrastructure.models import ParkCompany
from sqlalchemy import select
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == cid))).scalars().first()
if comp is None:
raise HTTPException(status_code=404, detail="企业不存在")
if await db.users.get_by_id(body.user_id) is None:
raise HTTPException(status_code=404, detail="用户不存在")
await db.company_members.add(body.user_id, cid, is_admin=True, created_by=actor.get("id", ""))
comp.owner_user_id = body.user_id
await db.session.commit()
await sync_user_affiliation(db, body.user_id)
await write_audit(db, action="company.bind_admin", resource="park_company",
resource_id=cid, detail=f"user={body.user_id}", user=actor, request=request)
return {"ok": True, "company_id": cid, "admin_user_id": body.user_id}
@router.post("/park-transfers", summary="运营端代提转园申请(两级审核仍走园区)")
async def admin_propose_transfer(
body: AdminTransferProposeBody,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...domain import park_transfer as pt_rules
from app.park import tenants as tnt
kind = body.target_kind if body.target_kind in pt_rules.TARGET_KINDS else "user"
if body.from_park_id == body.to_park_id:
raise HTTPException(status_code=400, detail="转出/转入园区相同")
from_t = await tnt.get_tenant(body.from_park_id)
to_t = await tnt.get_tenant(body.to_park_id)
if from_t is None or to_t is None:
raise HTTPException(status_code=404, detail="转出或转入园区不存在")
user_id = body.user_id
if kind == "company":
if not body.company_id:
raise HTTPException(status_code=400, detail="企业转园须提供企业 id")
comp = await tnt.get_company(body.company_id)
if comp is None or comp.get("tenant_id") != body.from_park_id:
raise HTTPException(status_code=400, detail="企业不属于转出园区")
user_id = ""
else:
if not user_id:
raise HTTPException(status_code=400, detail="用户转园须提供用户 id")
if await db.park_members.get(user_id, body.from_park_id) is None:
raise HTTPException(status_code=400, detail="目标用户不属于转出园区")
tr = await db.park_transfers.create({
"user_id": user_id, "username": (await db.users.get_by_id(user_id) or {}).get("username", "") if user_id else "",
"from_park_id": body.from_park_id, "from_park_name": from_t.get("name", ""),
"to_park_id": body.to_park_id, "to_park_name": to_t.get("name", ""),
"reason": body.reason, "stage": pt_rules.initial_stage(from_t), "status": "pending",
"initiator_role": "operator", "target_kind": kind, "company_id": body.company_id,
}, user_id, "")
await write_audit(db, action="park.transfer_propose", resource="park_transfer",
resource_id=tr["id"], detail=f"to={body.to_park_id} kind={kind}",
user=actor, request=request)
return {"ok": True, "transfer": tr}
@router.post("/park-transfers/{tid}/force", summary="运营端兜底终审(force: approved/rejected,任意阶段)")
async def admin_force_transfer(
tid: str,
body: ParkTransferReviewRequest,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_permission("action:user.manage")),
):
from ...domain import park_transfer as pt_rules
from ...services import membership_service
tr = await db.park_transfers.get(tid)
if tr is None:
raise HTTPException(status_code=404, detail="转园申请不存在")
if tr.get("stage") in ("approved", "rejected", "cancelled"):
raise HTTPException(status_code=400, detail="该申请已终态")
step = pt_rules.review(tr.get("stage", "pending_from"), side="operator", decision=body.status)
updated = await db.park_transfers.set_stage(
tid, stage=step["stage"], from_status=step["from_status"], to_status=step["to_status"],
operator_reviewer_id=actor.get("id", ""), comment=body.comment)
if step["stage"] == "approved":
if tr.get("target_kind") == "company" and tr.get("company_id"):
await membership_service.transfer_company_park(
db, tr["company_id"], tr.get("from_park_id", ""), tr.get("to_park_id", ""),
operator_id=actor.get("id", ""))
elif tr.get("user_id"):
await membership_service.transfer_user_park(
db, tr["user_id"], tr.get("from_park_id", ""), tr.get("to_park_id", ""),
operator_id=actor.get("id", ""))
await write_audit(db, action="park.transfer_force", resource="park_transfer",
resource_id=tid, detail=f"decision={body.status}", user=actor, request=request)
return {"ok": True, "transfer": updated}
# ===========================================================================
# 大厅与社区治理(0035):岗位 / OPC 服务 / 人才 / 社区帖子
# ===========================================================================
@router.get("/hall/jobs", summary="岗位列表(全状态)")
async def admin_hall_jobs(status: str = "", db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
return {"items": await db.hall_jobs.list(status=status)}
@router.post("/hall/jobs/{job_id}/status", summary="岗位审核/上下架")
async def admin_hall_job_status(job_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
updated = await db.hall_jobs.set_status(job_id, str(body.get("status", "")).strip())
if updated is None:
raise HTTPException(status_code=404, detail="岗位不存在")
await write_audit(db, action="admin.hall.job.status", resource="job", resource_id=job_id,
detail=str(body.get("status")), user=actor, request=request)
return {"ok": True, "item": updated}
@router.get("/hall/services", summary="OPC 服务列表(全状态)")
async def admin_hall_services(status: str = "", db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
return {"items": await db.hall_services.list(status=status)}
@router.post("/hall/services/{service_id}/status", summary="OPC 服务审核/上下架")
async def admin_hall_service_status(service_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
updated = await db.hall_services.update(service_id, {"status": str(body.get("status", "")).strip()})
if updated is None:
raise HTTPException(status_code=404, detail="服务不存在")
await write_audit(db, action="admin.hall.service.status", resource="opc_service",
resource_id=service_id, detail=str(body.get("status")), user=actor, request=request)
return {"ok": True, "item": updated}
@router.patch("/hall/services/{service_id}", summary="编辑 OPC 服务信息")
async def admin_hall_service_update(service_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
# 只允许编辑的字段,防止越权修改 rating/order_count 等系统字段
allowed = {"title", "category", "description", "cover", "gallery", "price",
"delivery_days", "tags", "status"}
patch = {k: v for k, v in body.items() if k in allowed}
if not patch:
raise HTTPException(status_code=400, detail="无有效编辑字段")
updated = await db.hall_services.update(service_id, patch)
if updated is None:
raise HTTPException(status_code=404, detail="服务不存在")
await write_audit(db, action="admin.hall.service.update", resource="opc_service",
resource_id=service_id, detail=json.dumps(patch, ensure_ascii=False),
user=actor, request=request)
return {"ok": True, "item": updated}
@router.get("/hall/talents", summary="人才档案列表(含未上架)")
async def admin_hall_talents(db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
return {"items": await db.hall_talents.list(published_only=False)}
@router.post("/hall/talents/{user_id}/published", summary="人才上架/下架")
async def admin_hall_talent_published(user_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
updated = await db.hall_talents.set_published(user_id, bool(body.get("published", True)))
await write_audit(db, action="admin.hall.talent.published", resource="talent_profile",
resource_id=user_id, detail=f"published={body.get('published')}", user=actor, request=request)
return {"ok": True, "item": updated}
@router.get("/hall/tasks", summary="大厅任务审核列表(pending 优先)")
async def admin_hall_tasks(status: str = "", db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
items = await db.tasks.list(status=status or None)
return {"items": items}
@router.post("/hall/tasks/{task_id}/status", summary="大厅任务审核(通过/驳回/下架)")
async def admin_hall_task_status(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
status = str(body.get("status", "")).strip()
if status not in ("published", "pending", "cancelled", "rejected"):
raise HTTPException(status_code=400, detail="非法状态")
updated = await db.tasks.set_status(task_id, status)
if updated is None:
raise HTTPException(status_code=404, detail="任务不存在")
await write_audit(db, action="admin.hall.task.status", resource="task", resource_id=task_id,
detail=status, user=actor, request=request)
return {"ok": True, "item": updated}
@router.get("/community/posts", summary="社区帖子列表(全状态)")
async def admin_community_posts(status: str = "", db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator"))):
return {"items": await db.community_posts.list(status=status)}
@router.post("/community/posts/{post_id}/status", summary="帖子隐藏/恢复")
async def admin_community_post_status(post_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
updated = await db.community_posts.set_status(post_id, str(body.get("status", "")).strip())
if updated is None:
raise HTTPException(status_code=404, detail="帖子不存在")
await write_audit(db, action="admin.community.post.status", resource="community_post",
resource_id=post_id, detail=str(body.get("status")), user=actor, request=request)
return {"ok": True, "item": updated}
@router.post("/community/posts/{post_id}/pinned", summary="帖子置顶/取消置顶")
async def admin_community_post_pinned(post_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_roles("operator"))):
updated = await db.community_posts.set_pinned(post_id, bool(body.get("pinned", False)))
if updated is None:
raise HTTPException(status_code=404, detail="帖子不存在")
await write_audit(db, action="admin.community.post.pinned", resource="community_post",
resource_id=post_id, detail=f"pinned={body.get('pinned')}", user=actor, request=request)
return {"ok": True, "item": updated}