7a8cf77775
问题:rbac_compute_pricing.py的路由前缀是/api/compute, 但dispatcher会去掉/api前缀后分发给core_app, 导致实际请求路径变成/compute/...,无法匹配/api/compute/...路由。 修复:将路由前缀从/api/compute改为/compute, 与其他路由(/admin、/hall等)保持一致。
1270 lines
57 KiB
Python
1270 lines
57 KiB
Python
# -*- 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")),
|
||
):
|
||
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}
|
||
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}
|