# -*- 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")), ): from sqlalchemy import text as sql_text user = await db.users.get_by_id(user_id) if user is None: raise HTTPException(status_code=404, detail="用户不存在") # 防止删除自己 if actor.get("id") == user_id: raise HTTPException(status_code=400, detail="不能删除当前登录账号") username = user.get("username", "") # ═══════════════════════════════════════════════════════════ # 级联删除所有与用户相关的数据 # 动态查询所有引用 users 表的外键约束,自动处理任意列名(user_id/claimed_by/creator_id等) # ═══════════════════════════════════════════════════════════ # 1. 查询当前数据库中所有引用 users(id) 的外键约束 fk_result = await db.session.execute( sql_text(""" SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME = 'users' AND REFERENCED_COLUMN_NAME = 'id' """) ) fk_refs = [(row[0], row[1]) for row in fk_result.fetchall()] # 2. 临时禁用外键检查,避免表间依赖顺序问题(如 task_claims -> tasks -> users) await db.session.execute(sql_text("SET FOREIGN_KEY_CHECKS = 0")) deleted_count = 0 # 3. 对每个外键引用,删除相关记录 for table_name, column_name in fk_refs: # 跳过 users 表自身的自引用(如果有) if table_name == "users": continue try: result = await db.session.execute( sql_text(f"DELETE FROM `{table_name}` WHERE `{column_name}` = :uid"), {"uid": user_id} ) deleted_count += result.rowcount or 0 except Exception as e: print(f"[delete_user] 跳过表 {table_name}.{column_name}: {e}") await db.session.rollback() # 3.1 清理无外键的归属/管理员引用(owner_user_id / company_members / park_members 均无外键约束, # 不在上面动态外键清理范围内,须手工处理,避免企业负责人/成员指向已删除账号) try: # 企业负责人:该用户被删 → owner 置空(企业保留,负责人交给运营重新绑定) await db.session.execute( sql_text("UPDATE `park_companies` SET `owner_user_id` = '' WHERE `owner_user_id` = :uid"), {"uid": user_id} ) # 企业成员/园区成员:该用户被删 → 删除成员关系行 await db.session.execute( sql_text("DELETE FROM `company_members` WHERE `user_id` = :uid"), {"uid": user_id} ) await db.session.execute( sql_text("DELETE FROM `park_members` WHERE `user_id` = :uid"), {"uid": user_id} ) except Exception as e: print(f"[delete_user] 归属引用清理失败: {e}") await db.session.rollback() # 4. 硬删除用户主记录(在外键检查禁用状态下执行,确保成功) try: await db.session.execute( sql_text("DELETE FROM users WHERE id = :uid"), {"uid": user_id} ) except Exception as e: await db.session.execute(sql_text("SET FOREIGN_KEY_CHECKS = 1")) await db.session.rollback() raise HTTPException(status_code=500, detail=f"删除用户失败: {e}") # 5. 恢复外键检查并提交 await db.session.execute(sql_text("SET FOREIGN_KEY_CHECKS = 1")) await db.session.commit() # 平台用户删除 → 引擎镜像同步删除(算力、IM等) if username: await _sync_compute(compute_client.sync_delete_user(username)) await write_audit(db, action="user.delete", resource="user", resource_id=user_id, detail=f"deleted, cascade_deleted={deleted_count} records", user=actor, request=request) return {"ok": True, "user_id": user_id, "username": username, "cascade_deleted": deleted_count} @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", "operator_user_id", "city", "address", "contact", "tags", "area_m2", "default_company_discount", "default_user_discount", "created_at")} @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.get("/park/tenants/{tid}", summary="园区详情(含统计:成员数/企业数,平台)") async def admin_park_tenant_detail( 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 t = await tnt.get_tenant(tid) if t is None: raise HTTPException(status_code=404, detail="园区不存在") # 统计数据 companies = await tnt.list_companies(tid) members = await db.park_members.list_by_park(tid) # 管理员信息 admin_user = None if t.get("operator_user_id"): admin_user = await db.users.get_by_id(t["operator_user_id"]) result = _park_tenant_public(t) result["company_count"] = len(companies) result["member_count"] = len(members) result["admin_user"] = {k: admin_user.get(k, "") for k in ("id", "username", "nickname", "phone", "avatar")} if admin_user else None result["companies"] = companies return result @router.get("/park/tenants/{tid}/members", summary="园区成员列表(含用户信息,平台)") async def admin_park_tenant_members( tid: str, db: Database = Depends(get_db), _role: dict = Depends(require_roles("operator")), _perm: dict = Depends(require_permission("menu:admin_user_mgmt")), ): from ...infrastructure.models import ParkCompany, CompanyMember from sqlalchemy import select members = await db.park_members.list_by_park(tid) items = [] for m in members: user = await db.users.get_by_id(m["user_id"]) d = dict(m) if user: d["user"] = {k: user.get(k, "") for k in ("id", "username", "nickname", "phone", "role", "avatar", "certification_status")} else: # 成员用户已删除(孤儿引用):显式标记 d["user"] = {"id": m["user_id"], "username": "", "nickname": "", "phone": "", "role": "", "avatar": "", "certification_status": "", "deleted": True} # 查询该用户在本园区下是否为企业管理员 d["company_admin"] = None try: cm_rows = (await db.session.execute( select(CompanyMember, ParkCompany) .join(ParkCompany, CompanyMember.company_id == ParkCompany.id) .where( CompanyMember.user_id == m["user_id"], ParkCompany.tenant_id == tid, CompanyMember.status == "active", ) )).all() for cm, pc in cm_rows: if cm.member_type == "admin" or cm.is_admin: d["company_admin"] = { "company_id": pc.id, "company_name": pc.name, } break except Exception: pass items.append(d) return {"items": items} @router.get("/companies/{cid}/members", summary="企业成员列表(含用户信息,平台)") async def admin_company_members( cid: str, db: Database = Depends(get_db), _role: dict = Depends(require_roles("operator")), ): 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="企业不存在") members = await db.company_members.list_by_company(cid) items = [] for m in members: user = await db.users.get_by_id(m["user_id"]) d = dict(m) if user: d["user"] = {k: user.get(k, "") for k in ("id", "username", "nickname", "phone", "role", "avatar", "certification_status")} else: # 成员用户已删除(孤儿引用):显式标记,前端展示"已删除账号" d["user"] = {"id": m["user_id"], "username": "", "nickname": "", "phone": "", "role": "", "avatar": "", "certification_status": "", "deleted": True} items.append(d) return {"items": items} @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 # 同步 founder(展示字段单一事实源) u = await db.users.get_by_id(uid) if u: comp.founder = u.get("nickname") or u.get("username") or comp.founder 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, resolve_company_owners items = [] for r in rows: d = _co(r) d["member_count"] = len(await db.company_members.list_by_company(r.id)) items.append(d) items = await resolve_company_owners(db.session, items) 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 # 同步 founder(展示字段单一事实源:绑定管理员时以用户昵称/用户名为准) u = await db.users.get_by_id(body.user_id) if u: comp.founder = u.get("nickname") or u.get("username") or comp.founder 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) # 实时通知:审核结果回传服务发布者 if updated.get("user_id") or updated.get("publisher_id"): from ...services.notification_service import notify as _n _st = updated.get("status", "") _title = {"published": "服务已上架", "pending": "服务提交审核", "rejected": "服务审核未通过", "offline": "服务已下架"}.get(_st, "服务状态更新") _lvl = "success" if _st == "published" else ("warning" if _st == "rejected" else "info") await _n(db, updated.get("user_id") or updated.get("publisher_id"), "ops", _title, f"你的服务「{updated.get('title', '')}」{_title}", event_code=f"ops.service_{_st or 'updated'}", level=_lvl, link="/opc/services", ref_type="opc_service", ref_id=service_id) 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) # 实时通知:任务审核结果回传发布者 from ...services.notification_service import notify as _n _title = {"published": "任务审核通过", "rejected": "任务审核未通过", "cancelled": "任务已下架", "pending": "任务已提交审核"}.get(status, "任务状态更新") _lvl = "success" if status == "published" else ("warning" if status in ("rejected", "cancelled") else "info") await _n(db, updated.get("publisher_id") or "", "task", _title, f"你的任务「{updated.get('title', '')}」{_title}", event_code=f"task.reviewed_{status}", level=_lvl, link="/opc/my-tasks", ref_type="task", ref_id=task_id) 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}