diff --git a/app/api/routers/rbac_ent_onboard.py b/app/api/routers/rbac_ent_onboard.py new file mode 100644 index 0000000..b1e5b83 --- /dev/null +++ b/app/api/routers/rbac_ent_onboard.py @@ -0,0 +1,504 @@ +# -*- 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] + # 企业管理员:本企业的 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="该申请已处理") + # 审核权校验 + if req.review_level == "park_admin": + if not await _is_park_admin(db, req.tenant_id, user): + 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] + 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): + 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} diff --git a/app/api/routers/rbac_teams.py b/app/api/routers/rbac_teams.py new file mode 100644 index 0000000..ce96625 --- /dev/null +++ b/app/api/routers/rbac_teams.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- +"""团队(非企业,/ent/teams)。 + +创建即生效(无需审核);支持成员加入/退出;支持升级为企业 +(升级 = 提交企业入驻申请,审核通过后团队标记 upgraded)。 +""" +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 Team, TeamMember, User +from ...infrastructure.repositories import Database, new_id, utcnow_iso + +router = APIRouter(prefix="/ent/teams", tags=["teams"]) + + +def _now() -> str: + return utcnow_iso() + + +def _team_dict(t, member_count: int = 0) -> dict: + return { + "id": t.id, "name": t.name, "bio": t.bio, + "owner_user_id": t.owner_user_id, "status": t.status, + "upgraded_company_id": t.upgraded_company_id, + "member_count": member_count, "created_at": t.created_at, + } + + +def _member_dict(m) -> dict: + return { + "id": m.id, "team_id": m.team_id, "user_id": m.user_id, + "role": m.role, "status": m.status, "created_at": m.created_at, + "left_at": m.left_at, + } + + +async def _get_team(db: Database, tid: str) -> Team: + t = (await db.session.execute(select(Team).where(Team.id == tid))).scalars().first() + if t is None: + raise HTTPException(status_code=404, detail="团队不存在") + return t + + +async def _can_manage(db: Database, team: Team, user: dict, *, need_owner: bool = False) -> bool: + """owner/admin 可管理;owner 专属(升级/解散)需 need_owner。""" + if team.owner_user_id == user.get("id", ""): + return True + if need_owner: + return False + m = await db.session.scalar(select(TeamMember).where( + TeamMember.team_id == team.id, TeamMember.user_id == user.get("id", ""), + TeamMember.status == "active")) + return bool(m and m.role in ("owner", "admin")) + + +class TeamCreate(BaseModel): + name: str + bio: str = "" + + +class TeamUpdate(BaseModel): + name: str = "" + bio: str = "" + + +class TeamMemberAdd(BaseModel): + user_id: str = "" # 二选一 + keyword: str = "" # 用户名/手机号搜索 + + +@router.post("", summary="创建团队(即建即用,无需审核)") +async def create_team( + body: TeamCreate, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + name = (body.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="请填写团队名称") + team = Team(id=new_id("team"), name=name, bio=body.bio.strip(), + owner_user_id=user["id"], status="active", created_at=_now()) + db.session.add(team) + await db.session.flush() + db.session.add(TeamMember(id=new_id("tm"), team_id=team.id, user_id=user["id"], + role="owner", status="active", created_at=_now())) + await db.session.commit() + await write_audit(db, action="team.create", resource="team", + resource_id=team.id, detail=f"name={name}", user=user, request=request) + return {"ok": True, "team": _team_dict(team, 1)} + + +@router.get("/mine", summary="我的团队(我创建的 + 我加入的)") +async def my_teams( + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + items = [] + rows = (await db.session.scalars( + select(Team).where(Team.owner_user_id == user["id"]) + .order_by(Team.created_at.desc()))).all() + seen = {t.id for t in rows} + for t in rows: + cnt = len(await db.session.scalars(select(TeamMember).where( + TeamMember.team_id == t.id, TeamMember.status == "active"))) + items.append(_team_dict(t, cnt)) + joined = (await db.session.scalars( + select(Team).join(TeamMember, TeamMember.team_id == Team.id).where( + TeamMember.user_id == user["id"], TeamMember.status == "active", + Team.owner_user_id != user["id"]))).all() + for t in joined: + if t.id in seen: + continue + cnt = len(await db.session.scalars(select(TeamMember).where( + TeamMember.team_id == t.id, TeamMember.status == "active"))) + items.append(_team_dict(t, cnt)) + return {"items": items} + + +@router.get("/{tid}", summary="团队详情 + 成员列表") +async def team_detail( + tid: str, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + team = await _get_team(db, tid) + members = (await db.session.scalars(select(TeamMember).where( + TeamMember.team_id == tid, TeamMember.status == "active") + .order_by(TeamMember.created_at))).all() + mlist = [] + for m in members: + d = _member_dict(m) + u = await db.users.get_by_id(m.user_id) + if u: + d["username"] = u.get("username", "") + d["nickname"] = u.get("nickname", "") + mlist.append(d) + my = await db.session.scalar(select(TeamMember).where( + TeamMember.team_id == tid, TeamMember.user_id == user["id"])) + return {"team": _team_dict(team, len(members)), "members": mlist, + "my_role": my.role if my else "", "is_member": bool(my and my.status == "active")} + + +@router.put("/{tid}", summary="编辑团队资料(owner/admin)") +async def update_team( + tid: str, + body: TeamUpdate, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + team = await _get_team(db, tid) + if not await _can_manage(db, team, user): + raise HTTPException(status_code=403, detail="仅团队管理员可编辑") + if body.name.strip(): + team.name = body.name.strip() + team.bio = body.bio.strip() + await db.session.commit() + await write_audit(db, action="team.update", resource="team", + resource_id=tid, user=user, request=request) + return {"ok": True} + + +@router.post("/{tid}/members", summary="添加成员(owner/admin,按用户搜索)") +async def add_member( + tid: str, + body: TeamMemberAdd, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + team = await _get_team(db, tid) + if not await _can_manage(db, team, user): + raise HTTPException(status_code=403, detail="仅团队管理员可添加成员") + target = None + if body.user_id.strip(): + target = await db.users.get_by_id(body.user_id.strip()) + elif body.keyword.strip(): + hits = await db.users.search(body.keyword.strip(), limit=1) + target = hits[0] if hits else None + if target is None: + raise HTTPException(status_code=404, detail="用户不存在(可按用户名/手机号搜索)") + existing = await db.session.scalar(select(TeamMember).where( + TeamMember.team_id == tid, TeamMember.user_id == target["id"])) + if existing is not None: + if existing.status == "active": + raise HTTPException(status_code=400, detail="该用户已在团队中") + existing.status = "active" + existing.left_at = "" + await db.session.commit() + else: + db.session.add(TeamMember(id=new_id("tm"), team_id=tid, user_id=target["id"], + role="member", status="active", created_at=_now())) + await db.session.commit() + await write_audit(db, action="team.add_member", resource="team", + resource_id=tid, detail=f"target={target.get('username','')}", + user=user, request=request) + return {"ok": True} + + +@router.delete("/{tid}/members/{uid}", summary="移出成员(owner/admin,owner 不可移出)") +async def remove_member( + tid: str, + uid: str, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + team = await _get_team(db, tid) + if not await _can_manage(db, team, user): + raise HTTPException(status_code=403, detail="仅团队管理员可移出成员") + m = await db.session.scalar(select(TeamMember).where( + TeamMember.team_id == tid, TeamMember.user_id == uid, TeamMember.status == "active")) + if m is None: + raise HTTPException(status_code=404, detail="成员不存在") + if m.role == "owner": + raise HTTPException(status_code=400, detail="不能移出团队创建者") + m.status = "left" + m.left_at = _now() + await db.session.commit() + await write_audit(db, action="team.remove_member", resource="team", + resource_id=tid, detail=f"target={uid}", user=user, request=request) + return {"ok": True} + + +@router.post("/{tid}/leave", summary="退出团队") +async def leave_team( + tid: str, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + team = await _get_team(db, tid) + if team.owner_user_id == user["id"]: + raise HTTPException(status_code=400, detail="团队创建者不能退出,可解散或升级") + m = await db.session.scalar(select(TeamMember).where( + TeamMember.team_id == tid, TeamMember.user_id == user["id"], TeamMember.status == "active")) + if m is None: + raise HTTPException(status_code=404, detail="你不在该团队中") + m.status = "left" + m.left_at = _now() + await db.session.commit() + await write_audit(db, action="team.leave", resource="team", + resource_id=tid, user=user, request=request) + return {"ok": True} + + +class TeamUpgrade(BaseModel): + """升级为企业:走企业入驻申请流程(复用 /admin/ent/companies/apply)。""" + entry_type: str = "park_entered" # park_entered/independent + tenant_id: str = "" + 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 = {} + + +@router.post("/{tid}/upgrade", summary="团队升级为企业(提交企业入驻申请)") +async def upgrade_team( + tid: str, + body: TeamUpgrade, + request: Request, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + team = await _get_team(db, tid) + if team.owner_user_id != user["id"]: + raise HTTPException(status_code=403, detail="仅团队创建者可升级企业") + if team.status == "upgraded": + raise HTTPException(status_code=400, detail="该团队已升级为企业") + # 复用企业入驻申请 + from .rbac_ent_onboard import CompanyApply, _apply_company_impl + payload = CompanyApply( + entry_type=body.entry_type, tenant_id=body.tenant_id, + company_name=body.company_name, legal_person=body.legal_person, + legal_phone=body.legal_phone, contact_phone=body.contact_phone, + address=body.address, industry=body.industry, + registered_capital=body.registered_capital, company_type=body.company_type, + bio=body.bio, docs=body.docs, upgrade_from_team_id=tid, + ) + return await _apply_company_impl(db, user, request, payload) diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index adcac58..4532513 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -2342,3 +2342,103 @@ class ContentReport(Base): processed_at: Mapped[str] = mapped_column(String(32), default="") processor_id: Mapped[str] = mapped_column(String(64), default="") result: Mapped[str] = mapped_column(String(255), default="") + + +# =========================================================================== +# 企业认领 / 企业入驻申请 / 团队(0062 身份链路上线) +# =========================================================================== + + +class CompanyClaimRequest(Base): + """企业认领申请(未绑定企业用户 → 认领园区入驻企业)。 + + claim_role: admin(认领为企业管理员,仅当企业无管理员时允许)/ member(认领为成员)。 + 审核人:admin 认领 → 园区管理员;member 认领 → 有企业管理员则企业管理员,否则园区管理员。 + 证明资料(姓名/身份证/手机号)仅存证供审核端查看。 + """ + + __tablename__ = "company_claim_requests" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True) # 申请人平台用户 id + username: Mapped[str] = mapped_column(String, default="") # 申请人登录名 + company_id: Mapped[str] = mapped_column(String, index=True) # 目标企业 + company_name: Mapped[str] = mapped_column(String, default="") # 冗余企业名 + tenant_id: Mapped[str] = mapped_column(String, default="") # 冗余所属园区 + tenant_name: Mapped[str] = mapped_column(String, default="") + claim_role: Mapped[str] = mapped_column(String, default="member") # admin/member + # 证明资料(存证,审核端查看) + real_name: Mapped[str] = mapped_column(String, default="") + id_card: Mapped[str] = mapped_column(String, default="") + phone: Mapped[str] = mapped_column(String, default="") + status: Mapped[str] = mapped_column(String, default="pending") # pending/approved/rejected + review_level: Mapped[str] = mapped_column(String, default="park_admin") # park_admin/company_admin + reviewer_id: Mapped[str] = mapped_column(String, default="") + reviewer_name: Mapped[str] = mapped_column(String, default="") + review_comment: Mapped[str] = mapped_column(String, default="") + reviewed_at: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class CompanyApplyRequest(Base): + """企业入驻申请(已有企业加入平台)。 + + entry_type: park_entered(入驻园区,园区管理员审核)/ independent(不入驻园区,运营方审核)。 + 审核通过后创建 ParkCompany(kind 对应)并把提交人设为企业管理员。 + """ + + __tablename__ = "company_apply_requests" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column(String, index=True) + username: Mapped[str] = mapped_column(String, default="") + entry_type: Mapped[str] = mapped_column(String, default="park_entered") # park_entered/independent + tenant_id: Mapped[str] = mapped_column(String, default="") # 入驻园区时必填 + tenant_name: Mapped[str] = mapped_column(String, default="") + company_name: Mapped[str] = mapped_column(String, default="") + legal_person: Mapped[str] = mapped_column(String, default="") # 法定代表人 + legal_phone: Mapped[str] = mapped_column(String, default="") + contact_phone: Mapped[str] = mapped_column(String, default="") + address: Mapped[str] = mapped_column(String, default="") + industry: Mapped[str] = mapped_column(String, default="") + registered_capital: Mapped[str] = mapped_column(String, default="") # 注册资本(万元) + company_type: Mapped[str] = mapped_column(String, default="") + bio: Mapped[str] = mapped_column(Text, default="") + docs_json: Mapped[str] = mapped_column(Text, default="{}") # 营业执照等资料 URL + status: Mapped[str] = mapped_column(String, default="pending") # pending/approved/rejected + review_level: Mapped[str] = mapped_column(String, default="park_admin") # park_admin/operator + upgrade_from_team_id: Mapped[str] = mapped_column(String, default="") # 团队升级来源(非空=团队升级申请) + reviewer_id: Mapped[str] = mapped_column(String, default="") + reviewer_name: Mapped[str] = mapped_column(String, default="") + review_comment: Mapped[str] = mapped_column(String, default="") + reviewed_at: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + +class Team(Base): + """团队(非企业,创建即生效,无需审核;支持后续升级为企业)。""" + + __tablename__ = "teams" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, default="") + bio: Mapped[str] = mapped_column(Text, default="") + owner_user_id: Mapped[str] = mapped_column(String, index=True) + status: Mapped[str] = mapped_column(String, default="active") # active/upgraded + upgraded_company_id: Mapped[str] = mapped_column(String, default="") # 升级后的企业 id + created_at: Mapped[str] = mapped_column(String, default="") + + +class TeamMember(Base): + """团队成员关系(多对多)。""" + + __tablename__ = "team_members" + __table_args__ = (UniqueConstraint("user_id", "team_id", name="uq_team_member"),) + + id: Mapped[str] = mapped_column(String, primary_key=True) + team_id: Mapped[str] = mapped_column(String, index=True) + user_id: Mapped[str] = mapped_column(String, index=True) + role: Mapped[str] = mapped_column(String, default="member") # owner/admin/member + status: Mapped[str] = mapped_column(String, default="active") # active/left + created_at: Mapped[str] = mapped_column(String, default="") + left_at: Mapped[str] = mapped_column(String, default="") diff --git a/app/main.py b/app/main.py index 5dfda36..9e86af4 100644 --- a/app/main.py +++ b/app/main.py @@ -32,6 +32,8 @@ from app.api.routers import rbac_training as rbac_training_router from app.api.routers import rbac_credit as rbac_credit_router from app.api.routers import rbac_ecosystem as rbac_ecosystem_router from app.api.routers import rbac_enterprise as rbac_enterprise_router +from app.api.routers import rbac_ent_onboard as rbac_ent_onboard_router +from app.api.routers import rbac_teams as rbac_teams_router from app.api.routers import rbac_desktop_updates as rbac_desktop_updates_router from app.api.routers import rbac_moderation as rbac_moderation_router from app.api.routers import templates as templates_router @@ -113,6 +115,8 @@ app.include_router(rbac_training_router.router) app.include_router(rbac_credit_router.router) app.include_router(rbac_ecosystem_router.router) app.include_router(rbac_enterprise_router.router) +app.include_router(rbac_ent_onboard_router.router) +app.include_router(rbac_teams_router.router) app.include_router(rbac_desktop_updates_router.router) app.include_router(rbac_desktop_updates_router.public_router) app.include_router(rbac_hall_router.community_router)