Files
server-core/app/api/routers/rbac_ent_onboard.py
T
Pine 3a1bb75417 fix: 运营方兜底审核园区级认领/入驻(admin 园区详情可用)
- claims/reviews、companies/applies/reviews:运营方可见全部待审园区级申请
- review_claim/review_apply:park_admin 级允许运营方兜底审核
2026-09-07 16:23:43 +08:00

520 lines
24 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 扩展):认领企业 + 企业入驻申请。
**认领企业**(未绑定企业用户 → 认领园区入驻企业):
- 仅支持搜索(选园区 → 搜企业),不支持列表;
- 认领目标必须是「园区入驻企业」(tenant_id 非空);
- claim_role=admin:仅当企业无管理员时可认领,园区管理员审核;
- claim_role=member:有企业管理员则企业管理员审核,无则园区管理员审核;
- 证明资料(姓名/身份证/手机号)仅存证供审核端查看。
**企业入驻申请**(已有企业加入平台):
- entry_type=park_entered:入驻园区,园区管理员审核;
- entry_type=independent:不入驻园区,运营方审核;
- 审核通过后创建 ParkCompany,提交人自动成为企业管理员。
"""
from __future__ import annotations
import json
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 (
CompanyApplyRequest, CompanyClaimRequest, CompanyMember,
ParkCompany, ParkTenant, User,
)
from ...infrastructure.repositories import Database, new_id, utcnow_iso
router = APIRouter(prefix="/admin/ent", tags=["enterprise-onboard"])
def _now() -> str:
return utcnow_iso()
def _claim_dict(c) -> dict:
return {
"id": c.id, "user_id": c.user_id, "username": c.username,
"company_id": c.company_id, "company_name": c.company_name,
"tenant_id": c.tenant_id, "tenant_name": c.tenant_name,
"claim_role": c.claim_role, "real_name": c.real_name,
"id_card": c.id_card, "phone": c.phone,
"status": c.status, "review_level": c.review_level,
"reviewer_id": c.reviewer_id, "reviewer_name": c.reviewer_name,
"review_comment": c.review_comment, "reviewed_at": c.reviewed_at,
"created_at": c.created_at,
}
def _apply_dict(a) -> dict:
try:
docs = json.loads(a.docs_json or "{}")
except Exception: # noqa: BLE001
docs = {}
return {
"id": a.id, "user_id": a.user_id, "username": a.username,
"entry_type": a.entry_type, "tenant_id": a.tenant_id,
"tenant_name": a.tenant_name, "company_name": a.company_name,
"legal_person": a.legal_person, "legal_phone": a.legal_phone,
"contact_phone": a.contact_phone, "address": a.address,
"industry": a.industry, "registered_capital": a.registered_capital,
"company_type": a.company_type, "bio": a.bio, "docs": docs,
"upgrade_from_team_id": a.upgrade_from_team_id,
"status": a.status, "review_level": a.review_level,
"reviewer_id": a.reviewer_id, "reviewer_name": a.reviewer_name,
"review_comment": a.review_comment, "reviewed_at": a.reviewed_at,
"created_at": a.created_at,
}
# ---------------------------------------------------------------------------
# 认领企业
# ---------------------------------------------------------------------------
class ClaimSubmit(BaseModel):
company_id: str
claim_role: str = "member" # admin/member
real_name: str = ""
id_card: str = ""
phone: str = ""
@router.get("/claims/search", summary="认领企业搜索(仅园区入驻企业,按园区过滤)")
async def claim_search(
tenant_id: str,
q: str = "",
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
tenant_id = (tenant_id or "").strip()
if not tenant_id:
raise HTTPException(status_code=400, detail="请先选择园区")
t = (await db.session.execute(
select(ParkTenant).where(ParkTenant.id == tenant_id))).scalars().first()
if t is None:
raise HTTPException(status_code=404, detail="园区不存在")
stmt = select(ParkCompany).where(
ParkCompany.tenant_id == tenant_id,
ParkCompany.status == "active",
ParkCompany.company_kind == "park_entered",
)
kw = (q or "").strip()
if kw:
stmt = stmt.where(ParkCompany.name.like(f"%{kw}%"))
rows = (await db.session.scalars(stmt.order_by(ParkCompany.created_at.desc()).limit(50))).all()
items = []
for c in rows:
admins = (await db.session.scalars(
select(CompanyMember).where(CompanyMember.company_id == c.id,
CompanyMember.is_admin.is_(True),
CompanyMember.status == "active"))).all()
items.append({
"id": c.id, "name": c.name, "zone": c.zone, "room": c.room,
"industry": c.industry, "bio": c.bio,
"has_admin": bool(admins),
})
return {"items": items}
@router.post("/claims", summary="提交企业认领申请")
async def submit_claim(
body: ClaimSubmit,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
cid = (body.company_id or "").strip()
role = (body.claim_role or "member").strip()
if role not in ("admin", "member"):
raise HTTPException(status_code=400, detail="认领角色不合法(admin/member")
if not (body.real_name.strip() and body.id_card.strip() and body.phone.strip()):
raise HTTPException(status_code=400, detail="请填写姓名、身份证号码、手机号证明资料")
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == cid))).scalars().first()
if comp is None:
raise HTTPException(status_code=404, detail="企业不存在")
if comp.tenant_id is None or not comp.tenant_id:
raise HTTPException(status_code=400, detail="仅可认领园区入驻企业")
# 已绑定该企业则无需认领
existing = await db.company_members.get(user["id"], cid)
if existing is not None and existing.get("status") == "active":
raise HTTPException(status_code=400, detail="你已在该企业中,无需认领")
dup = (await db.session.scalar(
select(CompanyClaimRequest).where(
CompanyClaimRequest.user_id == user["id"],
CompanyClaimRequest.company_id == cid,
CompanyClaimRequest.status == "pending")))
if dup is not None:
raise HTTPException(status_code=400, detail="已有待审核的认领申请")
admins = (await db.session.scalars(
select(CompanyMember).where(CompanyMember.company_id == cid,
CompanyMember.is_admin.is_(True),
CompanyMember.status == "active"))).all()
has_admin = bool(admins)
if role == "admin" and has_admin:
raise HTTPException(status_code=400, detail="该企业已有管理员,只能认领为企业成员")
# 审核人:admin→园区管理员;member→有企业管理员则企业管理员,否则园区管理员
review_level = "park_admin"
if role == "member" and has_admin:
review_level = "company_admin"
t = (await db.session.execute(
select(ParkTenant).where(ParkTenant.id == comp.tenant_id))).scalars().first()
req = CompanyClaimRequest(
id=new_id("claim"), user_id=user["id"], username=user.get("username", ""),
company_id=cid, company_name=comp.name,
tenant_id=comp.tenant_id or "", tenant_name=(t.name if t else "") or "",
claim_role=role, real_name=body.real_name.strip(),
id_card=body.id_card.strip(), phone=body.phone.strip(),
status="pending", review_level=review_level, created_at=_now(),
)
db.session.add(req)
await db.session.commit()
await write_audit(db, action="ent.claim_submit", resource="company_claim_request",
resource_id=req.id, detail=f"company={comp.name} role={role}", user=user, request=request)
return {"ok": True, "id": req.id, "review_level": review_level}
@router.get("/claims/mine", summary="我的认领申请")
async def my_claims(
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
rows = (await db.session.scalars(
select(CompanyClaimRequest).where(CompanyClaimRequest.user_id == user["id"])
.order_by(CompanyClaimRequest.created_at.desc()))).all()
return {"items": [_claim_dict(r) for r in rows]}
async def _is_park_admin(db: Database, tenant_id: str, user: dict) -> bool:
"""当前用户是否为该园区管理员(载体账号绑定)。"""
if not tenant_id:
return False
t = (await db.session.execute(
select(ParkTenant).where(ParkTenant.id == tenant_id))).scalars().first()
return bool(t and t.operator_user_id and t.operator_user_id == user.get("id", ""))
@router.get("/claims/reviews", summary="待我审核的认领申请(园区管理员/企业管理员/运营方兜底)")
async def claim_reviews(
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
items = []
# 园区管理员:本园区的 park_admin 认领(admin 认领 + 无管理员的成员认领)
tids = (await db.session.scalars(
select(ParkTenant.id).where(ParkTenant.operator_user_id == user.get("id", "")))).all()
if tids:
park_rows = (await db.session.scalars(
select(CompanyClaimRequest).where(
CompanyClaimRequest.tenant_id.in_(tids),
CompanyClaimRequest.status == "pending",
CompanyClaimRequest.review_level == "park_admin")
.order_by(CompanyClaimRequest.created_at.desc()))).all()
items += [_claim_dict(r) for r in park_rows]
# 运营方(admin 端园区详情兜底):全部待审园区级认领
elif user.get("role") == "operator":
op_rows = (await db.session.scalars(
select(CompanyClaimRequest).where(
CompanyClaimRequest.status == "pending",
CompanyClaimRequest.review_level == "park_admin")
.order_by(CompanyClaimRequest.created_at.desc()))).all()
items += [_claim_dict(r) for r in op_rows]
# 企业管理员:本企业的 company_admin 认领
for m in await db.company_members.admin_of(user["id"]):
co_rows = (await db.session.scalars(
select(CompanyClaimRequest).where(
CompanyClaimRequest.company_id == m["company_id"],
CompanyClaimRequest.status == "pending",
CompanyClaimRequest.review_level == "company_admin")
.order_by(CompanyClaimRequest.created_at.desc()))).all()
items += [_claim_dict(r) for r in co_rows]
return {"items": items}
class ReviewAction(BaseModel):
action: str = "approve" # approve/reject
comment: str = ""
@router.post("/claims/{cid}/review", summary="认领申请审核")
async def review_claim(
cid: str,
body: ReviewAction,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
if body.action not in ("approve", "reject"):
raise HTTPException(status_code=400, detail="审核动作不合法")
req = (await db.session.execute(
select(CompanyClaimRequest).where(CompanyClaimRequest.id == cid))).scalars().first()
if req is None:
raise HTTPException(status_code=404, detail="申请不存在")
if req.status != "pending":
raise HTTPException(status_code=400, detail="该申请已处理")
# 审核权校验(运营方兜底园区级审核,admin 端园区详情可用)
if req.review_level == "park_admin":
if not await _is_park_admin(db, req.tenant_id, user) and user.get("role") != "operator":
raise HTTPException(status_code=403, detail="仅该园区管理员可审核")
else: # company_admin
mine = await db.company_members.get(user["id"], req.company_id)
if mine is None or not mine.get("is_admin") or mine.get("status") != "active":
raise HTTPException(status_code=403, detail="仅该企业管理员可审核")
if body.action == "reject":
req.status = "rejected"
req.review_comment = body.comment.strip()
req.reviewer_id = user.get("id", "")
req.reviewer_name = user.get("username", "")
req.reviewed_at = _now()
await db.session.commit()
await write_audit(db, action="ent.claim_reject", resource="company_claim_request",
resource_id=req.id, detail=f"company={req.company_name}", user=user, request=request)
return {"ok": True, "status": "rejected"}
# approve:复查约束后落成员关系
admins = (await db.session.scalars(
select(CompanyMember).where(CompanyMember.company_id == req.company_id,
CompanyMember.is_admin.is_(True),
CompanyMember.status == "active"))).all()
if req.claim_role == "admin" and admins:
req.status = "rejected"
req.review_comment = "该企业已有管理员,认领失败"
req.reviewer_id = user.get("id", "")
req.reviewer_name = user.get("username", "")
req.reviewed_at = _now()
await db.session.commit()
return {"ok": False, "status": "rejected", "reason": "该企业已有管理员"}
is_admin = req.claim_role == "admin"
await db.company_members.add(
req.user_id, req.company_id,
is_admin=is_admin,
member_type="admin" if is_admin else "staff",
created_by=user.get("id", ""),
)
if is_admin:
comp = (await db.session.execute(
select(ParkCompany).where(ParkCompany.id == req.company_id))).scalars().first()
if comp is not None:
comp.owner_user_id = req.user_id
req.status = "approved"
req.review_comment = body.comment.strip()
req.reviewer_id = user.get("id", "")
req.reviewer_name = user.get("username", "")
req.reviewed_at = _now()
await db.session.commit()
await write_audit(db, action="ent.claim_approve", resource="company_claim_request",
resource_id=req.id, detail=f"company={req.company_name} role={req.claim_role}",
user=user, request=request)
return {"ok": True, "status": "approved"}
# ---------------------------------------------------------------------------
# 企业入驻申请
# ---------------------------------------------------------------------------
class CompanyApply(BaseModel):
entry_type: str = "park_entered" # park_entered/independent
tenant_id: str = "" # park_entered 必填
company_name: str
legal_person: str = ""
legal_phone: str = ""
contact_phone: str = ""
address: str = ""
industry: str = ""
registered_capital: str = ""
company_type: str = ""
bio: str = ""
docs: dict = {}
upgrade_from_team_id: str = ""
@router.post("/companies/apply", summary="企业入驻申请(已有企业加入平台)")
async def apply_company(
body: CompanyApply,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
return await _apply_company_impl(db, user, request, body)
async def _apply_company_impl(db: Database, user: dict, request: Request, body: CompanyApply) -> dict:
"""企业入驻申请校验+落库(供 /ent/teams/{tid}/upgrade 复用)。"""
entry = (body.entry_type or "park_entered").strip()
if entry not in ("park_entered", "independent"):
raise HTTPException(status_code=400, detail="入驻类型不合法")
name = (body.company_name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="请填写企业名称")
tenant_id = (body.tenant_id or "").strip()
if entry == "park_entered":
if not tenant_id:
raise HTTPException(status_code=400, detail="入驻园区必须选择园区")
t = (await db.session.execute(
select(ParkTenant).where(ParkTenant.id == tenant_id))).scalars().first()
if t is None:
raise HTTPException(status_code=404, detail="园区不存在")
tenant_name = t.name
else:
tenant_id = ""
tenant_name = ""
# 同名企业已存在则不能重复入驻
dup_co = (await db.session.scalar(
select(ParkCompany).where(ParkCompany.name == name)))
if dup_co is not None:
raise HTTPException(status_code=400, detail="该企业已入驻平台,可前往「认领企业」认领")
dup = (await db.session.scalar(
select(CompanyApplyRequest).where(
CompanyApplyRequest.user_id == user["id"],
CompanyApplyRequest.company_name == name,
CompanyApplyRequest.status == "pending")))
if dup is not None:
raise HTTPException(status_code=400, detail="已有同名企业待审核申请")
review_level = "park_admin" if entry == "park_entered" else "operator"
rec = CompanyApplyRequest(
id=new_id("apply"), user_id=user["id"], username=user.get("username", ""),
entry_type=entry, tenant_id=tenant_id, tenant_name=tenant_name,
company_name=name, legal_person=body.legal_person.strip(),
legal_phone=body.legal_phone.strip(), contact_phone=body.contact_phone.strip(),
address=body.address.strip(), industry=body.industry.strip(),
registered_capital=body.registered_capital.strip(),
company_type=body.company_type.strip(), bio=body.bio.strip(),
docs_json=json.dumps(body.docs or {}, ensure_ascii=False),
upgrade_from_team_id=(body.upgrade_from_team_id or "").strip(),
status="pending", review_level=review_level, created_at=_now(),
)
db.session.add(rec)
await db.session.commit()
await write_audit(db, action="ent.company_apply", resource="company_apply_request",
resource_id=rec.id, detail=f"company={name} entry={entry}",
user=user, request=request)
return {"ok": True, "id": rec.id, "review_level": review_level}
@router.get("/companies/applies/mine", summary="我的企业入驻申请")
async def my_applies(
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
rows = (await db.session.scalars(
select(CompanyApplyRequest).where(CompanyApplyRequest.user_id == user["id"])
.order_by(CompanyApplyRequest.created_at.desc()))).all()
return {"items": [_apply_dict(r) for r in rows]}
@router.get("/companies/applies/reviews", summary="待审企业入驻申请(园区管理员/运营方)")
async def apply_reviews(
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
items = []
if user.get("role") == "operator":
op_rows = (await db.session.scalars(
select(CompanyApplyRequest).where(
CompanyApplyRequest.status == "pending",
CompanyApplyRequest.review_level == "operator")
.order_by(CompanyApplyRequest.created_at.desc()))).all()
items += [_apply_dict(r) for r in op_rows]
# 运营方兜底:全部园区级企业入驻申请(admin 园区详情按园区过滤)
park_op_rows = (await db.session.scalars(
select(CompanyApplyRequest).where(
CompanyApplyRequest.status == "pending",
CompanyApplyRequest.review_level == "park_admin")
.order_by(CompanyApplyRequest.created_at.desc()))).all()
items += [_apply_dict(r) for r in park_op_rows]
tids = (await db.session.scalars(
select(ParkTenant.id).where(ParkTenant.operator_user_id == user.get("id", "")))).all()
if tids:
park_rows = (await db.session.scalars(
select(CompanyApplyRequest).where(
CompanyApplyRequest.tenant_id.in_(tids),
CompanyApplyRequest.status == "pending",
CompanyApplyRequest.review_level == "park_admin")
.order_by(CompanyApplyRequest.created_at.desc()))).all()
items += [_apply_dict(r) for r in park_rows]
return {"items": items}
@router.post("/companies/applies/{aid}/review", summary="企业入驻申请审核")
async def review_apply(
aid: str,
body: ReviewAction,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
if body.action not in ("approve", "reject"):
raise HTTPException(status_code=400, detail="审核动作不合法")
rec = (await db.session.execute(
select(CompanyApplyRequest).where(CompanyApplyRequest.id == aid))).scalars().first()
if rec is None:
raise HTTPException(status_code=404, detail="申请不存在")
if rec.status != "pending":
raise HTTPException(status_code=400, detail="该申请已处理")
# 审核权校验(运营方兜底园区级审核)
if rec.review_level == "operator":
if user.get("role") != "operator":
raise HTTPException(status_code=403, detail="仅运营方可审核该申请")
else:
if not await _is_park_admin(db, rec.tenant_id, user) and user.get("role") != "operator":
raise HTTPException(status_code=403, detail="仅该园区管理员可审核")
if body.action == "reject":
rec.status = "rejected"
rec.review_comment = body.comment.strip()
rec.reviewer_id = user.get("id", "")
rec.reviewer_name = user.get("username", "")
rec.reviewed_at = _now()
await db.session.commit()
await write_audit(db, action="ent.company_apply_reject", resource="company_apply_request",
resource_id=rec.id, detail=f"company={rec.company_name}", user=user, request=request)
return {"ok": True, "status": "rejected"}
# approve:同名企业复查 → 创建企业 + 提交人设管理员
dup_co = (await db.session.scalar(
select(ParkCompany).where(ParkCompany.name == rec.company_name)))
if dup_co is not None:
rec.status = "rejected"
rec.review_comment = "企业已存在,请改用认领"
rec.reviewer_id = user.get("id", "")
rec.reviewer_name = user.get("username", "")
rec.reviewed_at = _now()
await db.session.commit()
return {"ok": False, "status": "rejected", "reason": "企业已存在"}
kind = "park_entered" if rec.entry_type == "park_entered" else "independent"
comp = ParkCompany(
id=new_id("PC"), tenant_id=rec.tenant_id or None,
company_kind=kind, name=rec.company_name,
industry=rec.industry, bio=rec.bio, founder=rec.legal_person,
status="active", legal_person=rec.legal_person, legal_phone=rec.legal_phone,
contact_phone=rec.contact_phone, address=rec.address,
registered_capital=rec.registered_capital, company_type=rec.company_type,
owner_user_id=rec.user_id, created_at=_now(),
)
db.session.add(comp)
await db.session.flush()
await db.company_members.add(rec.user_id, comp.id, is_admin=True,
member_type="owner", created_by=user.get("id", ""))
rec.status = "approved"
rec.review_comment = body.comment.strip()
rec.reviewer_id = user.get("id", "")
rec.reviewer_name = user.get("username", "")
rec.reviewed_at = _now()
await db.session.commit()
# 团队升级:标记团队已升级并关联企业
if rec.upgrade_from_team_id:
from ...infrastructure.models import Team
team = (await db.session.execute(
select(Team).where(Team.id == rec.upgrade_from_team_id))).scalars().first()
if team is not None and team.owner_user_id == rec.user_id:
team.status = "upgraded"
team.upgraded_company_id = comp.id
await db.session.commit()
await write_audit(db, action="ent.company_apply_approve", resource="company_apply_request",
resource_id=rec.id, detail=f"company={rec.company_name} company_id={comp.id}",
user=user, request=request)
return {"ok": True, "status": "approved", "company_id": comp.id}