Files
server-core/app/api/routers/rbac_enterprise.py
T

267 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""企业管理能力端点(/admin/ent)。
**企业管理员不是身份**:用户身份只有 运营方/载体方/OPC;「企业管理员」是
`company_members.is_admin` 能力标记(通常是园区入驻企业的 OPC 成员)。
任何登录用户只要 is_admin 绑定了企业,即可在本端点管理该企业:
查看/编辑企业资料、绑定企业用户(可代客开户)、发起企业整体转园申请。
铁律:不可删除企业/用户;不可直接调整所属园区(转园申请是唯一路径)。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from sqlalchemy import select
from ..dependencies import get_db, get_current_user
from ...rbac import write_audit
from ...infrastructure.models import ParkCompany
from ...infrastructure.repositories import Database
from app.park import tenants as tnt
router = APIRouter(prefix="/admin/ent", tags=["enterprise"])
async def _ent_user(user: dict = Depends(get_current_user)) -> dict:
"""企业管理能力端点登录态(能力校验在各公司维度做 is_admin)。"""
return user
async def _my_company(db: Database, company_id: str, user: dict) -> ParkCompany:
"""校验当前用户是该企业的 is_admin 成员,返回企业 ORM 行。"""
m = await db.company_members.get(user["id"], company_id)
if m is None or not m.get("is_admin"):
raise HTTPException(status_code=403, detail="仅企业管理员可操作该企业")
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == company_id))).scalars().first()
if comp is None:
raise HTTPException(status_code=404, detail="企业不存在")
return comp
@router.get("/my", summary="我管理的企业(is_admin 能力,非身份)")
async def ent_my(
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
items = []
for m in await db.company_members.admin_of(user["id"]):
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == m["company_id"]))).scalars().first()
if comp is None:
continue
d = tnt._co(comp)
d["member_count"] = len(await db.company_members.list_by_company(comp.id))
items.append(d)
return {"items": items}
@router.get("/my/joined", summary="我加入的企业(含普通成员,标注 my_is_admin")
async def ent_my_joined(
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
"""返回当前用户加入的所有企业,每个企业标注 my_is_admin 和我的成员信息。"""
# 预加载所有园区,用于 tenant_id → 园区名称 映射
all_tenants = await tnt.list_tenants()
tenant_name_map = {t["id"]: t["name"] for t in all_tenants}
items = []
for m in await db.company_members.list_by_user(user["id"]):
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == m["company_id"]))).scalars().first()
if comp is None:
continue
d = tnt._co(comp)
d["tenant_name"] = tenant_name_map.get(comp.tenant_id, comp.tenant_id or "")
d["my_is_admin"] = bool(m.get("is_admin"))
d["my_membership"] = {
"user_id": user["id"],
"company_id": comp.id,
"is_admin": bool(m.get("is_admin")),
"member_type": m.get("member_type", ""),
"role": m.get("role", ""),
"compute_balance": m.get("compute_balance", 0),
"compute_balance_used": m.get("compute_balance_used", 0),
"created_at": m.get("created_at", ""),
}
d["member_count"] = len(await db.company_members.list_by_company(comp.id))
items.append(d)
return {"items": items}
@router.get("/companies/{cid}", summary="企业详情(本企业管理员)")
async def ent_company(
cid: str,
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
comp = await _my_company(db, cid, user)
d = tnt._co(comp)
d["members"] = await tnt.company_members(cid)
return d
class EntCompanyUpdateBody(BaseModel):
name: str | None = None
industry: str | None = None
bio: str | None = None
address: str | None = None
contact_phone: str | None = None
legal_person: str | None = None
legal_phone: str | None = None
registered_capital: str | None = None
founded_at: str | None = None
employees: int | None = None
@router.put("/companies/{cid}", summary="编辑企业资料(不可删除、不可改所属园区)")
async def ent_update_company(
cid: str,
body: EntCompanyUpdateBody,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
comp = await _my_company(db, cid, user)
for k, v in body.model_dump(exclude_none=True).items():
setattr(comp, k, v)
await db.session.commit()
await write_audit(db, action="ent.company_edit", resource="park_company",
resource_id=cid, user=user, request=request)
return tnt._co(comp)
class EntMemberBody(BaseModel):
"""绑定企业用户:mode=existinguser_id/keyword 命中已有账号)| new(代客开户,默认 opc_member 防提权)。"""
mode: str = "existing"
user_id: str = ""
keyword: str = ""
phone: str = ""
password: str = ""
nickname: str = ""
@router.get("/companies/{cid}/members", summary="企业成员列表")
async def ent_members(
cid: str,
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
await _my_company(db, cid, user)
return {"items": await tnt.company_members(cid)}
@router.post("/companies/{cid}/members", summary="绑定企业用户(绑定已有 / 代客开户)")
async def ent_add_member(
cid: str,
body: EntMemberBody,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
target_id = body.user_id
if body.mode == "new":
from ...services.user_admin_service import create_username, validate_phone
if not body.nickname.strip() or not body.password:
raise HTTPException(status_code=400, detail="代客开户须提供姓名与初始密码")
phone = validate_phone(body.phone)
if await db.users.find_by_phone(phone):
raise HTTPException(status_code=400, detail="手机号已被使用")
uname = await create_username(db)
u = await db.users.create(uname, body.password, nickname=body.nickname, phone=phone,
source="enterprise", role="opc_member")
target_id = u["id"]
elif not target_id and body.keyword.strip():
hits = await db.users.search(body.keyword, limit=1)
if not hits:
raise HTTPException(status_code=404, detail="未找到该账号")
target_id = hits[0]["id"]
if await db.users.get_by_id(target_id) is None:
raise HTTPException(status_code=404, detail="用户不存在")
r = await tnt.add_company_member(cid, target_id, created_by=user.get("id", ""))
if r is None:
raise HTTPException(status_code=404, detail="企业不存在")
from ...services.membership_service import sync_user_affiliation
await sync_user_affiliation(db, target_id)
await write_audit(db, action="ent.member_bind", resource="company_member",
resource_id=target_id, detail=f"company={cid}", user=user, request=request)
return r
@router.delete("/companies/{cid}/members/{uid}", summary="移出企业用户(管理员不可被移出)")
async def ent_remove_member(
cid: str,
uid: str,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
await _my_company(db, cid, user)
r = await tnt.remove_company_member(cid, uid)
if r is None or not r.get("ok"):
raise HTTPException(status_code=400, detail=(r or {}).get("reason", "移出失败"))
from ...services.membership_service import sync_user_affiliation
await sync_user_affiliation(db, uid)
await write_audit(db, action="ent.member_remove", resource="company_member",
resource_id=uid, detail=f"company={cid}", user=user, request=request)
return r
class EntTransferBody(BaseModel):
to_park_id: str
reason: str = ""
@router.post("/park-transfers", summary="企业管理员发起企业整体转园申请(两级审核)")
async def ent_propose_transfer(
body: EntTransferBody,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
from ...domain import park_transfer as pt_rules
from ...infrastructure.models import ParkTenant
comps = await db.company_members.admin_of(user["id"])
if not comps:
raise HTTPException(status_code=403, detail="您未管理任何企业")
if len(comps) > 1:
raise HTTPException(status_code=400, detail="您管理多家企业,请分别在各企业上下文发起")
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == comps[0]["company_id"]))).scalars().first()
if comp is None:
raise HTTPException(status_code=404, detail="企业不存在")
from_park_id = comp.tenant_id or ""
if not from_park_id:
raise HTTPException(status_code=400, detail="独立企业无所属园区,无需转园")
if body.to_park_id == from_park_id:
raise HTTPException(status_code=400, detail="目标园区与当前园区相同")
to_t = (await db.session.execute(
select(ParkTenant).where(ParkTenant.id == body.to_park_id))).scalars().first()
if to_t is None:
raise HTTPException(status_code=404, detail="目标园区不存在")
from_t = await tnt.get_tenant(from_park_id)
tr = await db.park_transfers.create({
"user_id": "", "username": user.get("username", ""),
"from_park_id": from_park_id, "from_park_name": (from_t or {}).get("name", ""),
"to_park_id": body.to_park_id, "to_park_name": getattr(to_t, "name", ""),
"reason": body.reason, "stage": pt_rules.initial_stage(from_t), "status": "pending",
"initiator_role": "enterprise", "target_kind": "company", "company_id": comp.id,
}, "", user.get("username", ""))
await write_audit(db, action="ent.transfer_apply", resource="park_transfer",
resource_id=tr["id"], detail=f"to={body.to_park_id}", user=user, request=request)
return {"ok": True, "transfer": tr}
@router.get("/park-transfers", summary="本企业相关转园申请")
async def ent_transfers(
db: Database = Depends(get_db),
user: dict = Depends(_ent_user),
):
comps = await db.company_members.admin_of(user["id"])
items = []
for m in comps:
items.extend(await db.park_transfers.list(company_id=m["company_id"]))
return {"items": items}