From 8697119aaa191bda912c8875b643f6282da05235 Mon Sep 17 00:00:00 2001 From: Pine Date: Wed, 26 Aug 2026 19:57:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(park-company):=20=E5=9B=AD=E5=8C=BA?= =?UTF-8?q?=E4=BC=81=E4=B8=9A=E2=86=92=E6=88=90=E5=91=98=20+=20=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E7=AE=97=E5=8A=9B(=E6=8A=98=E6=89=A3%/=E9=85=8D?= =?UTF-8?q?=E9=A2=9D=EF=BC=8C=E5=8F=AA=E5=A2=9E=E4=B8=8D=E5=87=8F)=20?= =?UTF-8?q?=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移0015:park_companies 加 compute_discount/compute_quota/compute_quota_used/engine_group/owner_user_id;users 加 park_company_id(成员归属一家企业) - tenants.py:company_members/get/add/remove(写 users.park_company_id)、available_members(候选)、set_company_compute(折扣%/配额 单调非降,越界 ValueError)、sync_company_engine(引擎组倍率同步,best-effort) - park/routers.py:carrier 端点 company-members GET/POST/DELETE、company-pool、companies/{cid}/compute(monotonic+引擎同步);鉴权 _carrier_user+_my_park 隔离本园区 - compute_client.py:set_user_group_by_username、set_group_group_ratio(组倍率=1-discount%)、grant_user_quota_by_username(解析引擎id后 add_quota) - rbac_admin.py:平台兜底 GET /park/tenants/{tid}/companies、PUT .../companies/{cid}/compute(monotonic+audit) - _user_to_dict 暴露 park_company_id - 单调校验服务端强判:折扣降/配额负增 → 400 Co-Authored-By: Claude --- alembic/versions/0015_company_compute.py | 35 ++++++ app/api/routers/rbac_admin.py | 48 +++++++- app/infrastructure/models.py | 7 ++ app/infrastructure/repositories.py | 1 + app/park/routers.py | 79 +++++++++++++ app/park/tenants.py | 142 ++++++++++++++++++++++- app/services/compute_client.py | 40 +++++++ 7 files changed, 346 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/0015_company_compute.py diff --git a/alembic/versions/0015_company_compute.py b/alembic/versions/0015_company_compute.py new file mode 100644 index 0000000..2471091 --- /dev/null +++ b/alembic/versions/0015_company_compute.py @@ -0,0 +1,35 @@ +"""园区企业算力 + 用户企业归属 + +Revision ID: 0015_company_compute +Revises: 0014_drop_user_identities +Create Date: 2026-08-26 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0015_company_compute" +down_revision = "0014_drop_user_identities" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 园区企业:算力折扣%/配额(只增不减) + op.add_column("park_companies", sa.Column("compute_discount", sa.Integer(), nullable=False, server_default="0")) + op.add_column("park_companies", sa.Column("compute_quota", sa.Integer(), nullable=False, server_default="0")) + op.add_column("park_companies", sa.Column("compute_quota_used", sa.Integer(), nullable=False, server_default="0")) + op.add_column("park_companies", sa.Column("engine_group", sa.String(), nullable=False, server_default="")) + op.add_column("park_companies", sa.Column("owner_user_id", sa.String(), nullable=False, server_default="")) + # 用户归属园区企业(成员) + op.add_column("users", sa.Column("park_company_id", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("users", "park_company_id") + op.drop_column("park_companies", "owner_user_id") + op.drop_column("park_companies", "engine_group") + op.drop_column("park_companies", "compute_quota_used") + op.drop_column("park_companies", "compute_quota") + op.drop_column("park_companies", "compute_discount") diff --git a/app/api/routers/rbac_admin.py b/app/api/routers/rbac_admin.py index 1f69cd4..1a563cc 100644 --- a/app/api/routers/rbac_admin.py +++ b/app/api/routers/rbac_admin.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field from ..dependencies import get_db from ..schemas.admin import SetUserRoleRequest, SetUserStatusRequest, UserClassificationRequest @@ -456,3 +456,49 @@ async def admin_park_unbind_device( 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.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} diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 227f4d3..c0a7f9b 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -73,6 +73,7 @@ class User(Base): affiliation: Mapped[str] = mapped_column(String, default="independent") # independent:独立OPC | park:所属园区 park_id: Mapped[str] = mapped_column(String, default="") # 所属园区(id) park_name: Mapped[str] = mapped_column(String, default="") # 所属园区(名) + park_company_id: Mapped[str | None] = mapped_column(String, nullable=True) # 所属园区企业(id),成员归属一家企业 account_type: Mapped[str] = mapped_column(String, default="opc_default") # opc_default|park_staff|platform_staff|provider|enterprise # 小程序/网页「报名资料」字段(现状标签 + 关注主题 JSON) opc_status: Mapped[str] = mapped_column(String, default="") @@ -791,6 +792,12 @@ class ParkCompany(Base): founder: Mapped[str] = mapped_column(String, default="") status: Mapped[str] = mapped_column(String, default="applying") employees: Mapped[int | None] = mapped_column(Integer, nullable=True) + # 企业算力(只增不减):折扣% off 模型价;配额为发放给成员的算力额度 + compute_discount: Mapped[int] = mapped_column(Integer, default=0) # 折扣%(0-100,单调非降) + compute_quota: Mapped[int] = mapped_column(Integer, default=0) # 企业算力配额(单调非降/增量) + compute_quota_used: Mapped[int] = mapped_column(Integer, default=0) # 已分配/已用配额(统计) + engine_group: Mapped[str] = mapped_column(String, default="") # 引擎用户组名(折扣倍率载体) + owner_user_id: Mapped[str] = mapped_column(String, default="") # 企业联系人/管理员账号id(可选) created_at: Mapped[str] = mapped_column(String, default="") diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 3225af7..76ce2bd 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -186,6 +186,7 @@ def _user_to_dict(u: User) -> dict: "affiliation": u.affiliation, "park_id": u.park_id, "park_name": u.park_name, + "park_company_id": u.park_company_id, "account_type": u.account_type, "opc_status": u.opc_status, "topics": u.topics, diff --git a/app/park/routers.py b/app/park/routers.py index 926d8f1..367e1e6 100644 --- a/app/park/routers.py +++ b/app/park/routers.py @@ -1000,3 +1000,82 @@ async def carrier_park_transfer_review( resource_id=tid, detail=f"status={st}", user=user, request=request) return {"ok": True, "transfer": updated} + +# ==================== 园区企业:成员 + 算力(carrier 本园区) ==================== + +class CompanyMemberBody(BaseModel): + company_id: str + user_id: str + + +class CompanyComputeBody(BaseModel): + discount: int | None = Field(default=None, ge=0, le=100) + quota_add: int = Field(default=0, ge=0) + + +async def _own_company(t: dict, company_id: str) -> str: + """校验企业归属本园区,返回 company 数据;否则 403。""" + c = await tenants.get_company(company_id) + if c is None: + raise HTTPException(status_code=404, detail="企业不存在") + if c.get("tenant_id") != t["id"]: + raise HTTPException(status_code=403, detail="仅可管理本园区企业") + return c + + +@router.get("/api/company-members", summary="园区企业成员列表") +async def carrier_company_members(company_id: str, user: dict = Depends(_carrier_user)): + t = await _my_park(user) + await _own_company(t, company_id) + return {"items": await tenants.company_members(company_id)} + + +@router.post("/api/company-members", summary="把用户加入企业(成员)") +async def carrier_company_add_member(body: CompanyMemberBody, user: dict = Depends(_carrier_user)): + t = await _my_park(user) + await _own_company(t, body.company_id) + r = await tenants.add_company_member(body.company_id, body.user_id) + if r is None: + raise HTTPException(status_code=404, detail="企业或用户不存在") + return r + + +@router.delete("/api/company-members/{company_id}/{user_id}", summary="把用户移出企业") +async def carrier_company_remove_member(company_id: str, user_id: str, user: dict = Depends(_carrier_user)): + t = await _my_park(user) + await _own_company(t, company_id) + r = await tenants.remove_company_member(company_id, user_id) + if r is None: + raise HTTPException(status_code=404, detail="企业或用户不存在") + if not r.get("ok"): + raise HTTPException(status_code=400, detail=r.get("reason", "该用户不隶属此企业")) + return r + + +@router.get("/api/company-pool", summary="本园区可加入企业的成员候选") +async def carrier_company_pool(user: dict = Depends(_carrier_user)): + t = await _my_park(user) + return {"items": await tenants.available_members(t["id"])} + + +@router.put("/api/companies/{cid}/compute", summary="设置企业算力(折扣%/配额,只增不减)+ 同步引擎") +async def carrier_company_compute(cid: str, body: CompanyComputeBody, user: dict = Depends(_carrier_user)): + from ..services import compute_client + t = await _my_park(user) + await _own_company(t, cid) + try: + updated = await tenants.set_company_compute(cid, discount=body.discount, quota_add=body.quota_add) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + # best-effort 引擎同步:折扣 → 用户组倍率;配额 → 逐成员充值(失败不阻断本地记录) + await tenants.sync_company_engine(cid) + if body.quota_add > 0: + for m in await tenants.company_members(cid): + try: + await compute_client.grant_user_quota_by_username(m["username"], body.quota_add) + except Exception as exc: # noqa: BLE001 + logging.getLogger(__name__).warning("企业配额发放失败 %s: %s", m.get("username"), exc) + return {"ok": True, "company": updated, "quota_add": body.quota_add} + diff --git a/app/park/tenants.py b/app/park/tenants.py index 7093273..4059531 100644 --- a/app/park/tenants.py +++ b/app/park/tenants.py @@ -15,12 +15,12 @@ import secrets import time import uuid -from sqlalchemy import delete, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from ..config import DATABASE_URL from ..infrastructure.db import make_async_engine -from ..infrastructure.models import ParkCompany, ParkKbDoc, ParkScreen, ParkSetting, ParkTenant +from ..infrastructure.models import ParkCompany, ParkKbDoc, ParkScreen, ParkSetting, ParkTenant, User from .sim_engine import DEFAULT_DATA, refresh_engine _DEFAULT_TENANT_ID = "T001" @@ -140,7 +140,10 @@ def _to_tenant(row) -> dict: def _co(r) -> dict: return {"id": r.id, "name": r.name, "zone": r.zone, "room": r.room, "industry": r.industry, "bio": r.bio, - "founder": r.founder, "status": r.status, "employees": r.employees, "created_at": r.created_at} + "founder": r.founder, "status": r.status, "employees": r.employees, "created_at": r.created_at, + "compute_discount": r.compute_discount, "compute_quota": r.compute_quota, + "compute_quota_used": r.compute_quota_used, "engine_group": r.engine_group, + "owner_user_id": r.owner_user_id} def _kb(r) -> dict: @@ -292,7 +295,12 @@ async def ensure_default_tenant() -> str: async def list_companies(tenant_id: str) -> list[dict]: async with _get_session() as s: rows = (await s.execute(select(ParkCompany).where(ParkCompany.tenant_id == tenant_id).order_by(ParkCompany.created_at.desc()))).scalars().all() - return [_co(r) for r in rows] + out = [] + for r in rows: + d = _co(r) + d["member_count"] = await company_member_count(s, r.id) + out.append(d) + return out async def create_company(tenant_id: str, payload: dict) -> dict: @@ -308,7 +316,7 @@ async def create_company(tenant_id: str, payload: dict) -> dict: async def update_company(tenant_id: str, cid: str, payload: dict) -> dict | None: - fields = {k: v for k, v in payload.items() if v is not None and k in ("name", "zone", "room", "industry", "bio", "founder", "status", "employees")} + fields = {k: v for k, v in payload.items() if v is not None and k in ("name", "zone", "room", "industry", "bio", "founder", "status", "employees", "owner_user_id")} if not fields: return None async with _get_session() as s: @@ -332,6 +340,130 @@ async def delete_company(tenant_id: str, cid: str) -> bool: return ok +async def get_company(company_id: str) -> dict | None: + async with _get_session() as s: + row = await s.get(ParkCompany, company_id) + return _co(row) if row else None + + +async def company_member_count(s, company_id: str) -> int: + """统计企业成员数(users.park_company_id 命中)。""" + return await s.scalar(select(func.count(User.id)).where(User.park_company_id == company_id)) or 0 + + +async def company_members(company_id: str) -> list[dict]: + """企业成员列表(用户侧归属)。""" + async with _get_session() as s: + rows = (await s.execute( + select(User).where(User.park_company_id == company_id).order_by(User.created_at.desc()) + )).scalars().all() + return [{ + "id": r.id, "username": r.username, "nickname": r.nickname or "", + "phone": r.phone or "", "role": r.role or "", + "certification_status": r.certification_status or "uncertified", + "compute_quota": r.compute_quota or 0, "compute_used_quota": r.compute_used_quota or 0, + "created_at": r.created_at or "", + } for r in rows] + + +async def add_company_member(company_id: str, user_id: str) -> dict | None: + """把用户归属到该企业(成员)。""" + async with _get_session() as s: + u = await s.get(User, user_id) + c = await s.get(ParkCompany, company_id) + if u is None or c is None: + return None + u.park_company_id = company_id + u.updated_at = _now() + await s.commit() + return {"ok": True, "company_id": company_id, "user_id": user_id} + + +async def remove_company_member(company_id: str, user_id: str) -> dict | None: + """把用户移出该企业(清空归属)。""" + async with _get_session() as s: + u = await s.get(User, user_id) + c = await s.get(ParkCompany, company_id) + if u is None or c is None: + return None + if u.park_company_id != company_id: + return {"ok": False, "reason": "该用户不隶属此企业"} + u.park_company_id = None + u.updated_at = _now() + await s.commit() + return {"ok": True, "company_id": company_id, "user_id": user_id} + + +async def available_members(tenant_id: str) -> list[dict]: + """园区内可加入企业的成员候选:affiliation=park 且属本园区、尚未归属企业、且为 opc_member。""" + async with _get_session() as s: + rows = (await s.execute( + select(User).where( + User.affiliation == "park", + User.park_id == tenant_id, + User.role == "opc_member", + User.park_company_id.is_(None), + ).order_by(User.created_at.desc()) + )).scalars().all() + return [{"id": r.id, "username": r.username, "nickname": r.nickname or "", + "phone": r.phone or "", "certification_status": r.certification_status or "uncertified"} for r in rows] + + +async def set_company_compute(company_id: str, discount: int | None = None, quota_add: int = 0) -> dict: + """企业算力:折扣%/配额只增不减(服务端强校验)。 + + - discount:单调非降,任何 < 现值 → raise ValueError「折扣只能增加不能降低」。 + - quota_add:增量,负增量 → raise ValueError「配额只能增加不能降低」。 + 返回更新后的企业(含成员数)。 + """ + async with _get_session() as s: + c = await s.get(ParkCompany, company_id) + if c is None: + raise LookupError("企业不存在") + if discount is not None: + discount = max(0, min(100, int(discount))) + if discount < c.compute_discount: + raise ValueError("折扣只能增加不能降低") + c.compute_discount = discount + if quota_add: + if quota_add < 0: + raise ValueError("配额只能增加不能降低") + c.compute_quota += int(quota_add) + await s.commit() + await s.refresh(c) + d = _co(c) + d["member_count"] = await company_member_count(s, company_id) + d["quota_add"] = int(quota_add or 0) + return d + + +async def sync_company_engine(company_id: str) -> dict: + """按企业当前折扣同步引擎(best-effort):为成员设用户组 + 该组对默认组的倍率。 + + 引擎计费 quota = modelPrice × groupRatio,故倍率 = 1 - discount% 即自动打折。失败不阻断。 + """ + from ..services import compute_client + c = await get_company(company_id) + if not c: + return {"ok": False, "reason": "企业不存在"} + discount = c.get("compute_discount", 0) + group = c.get("engine_group") or f"pc_{company_id}" + if discount <= 0 and not group: + return {"ok": True} + members = await company_members(company_id) + for m in members: + try: + await compute_client.set_user_group_by_username(m["username"], group) + except Exception: # noqa: BLE001 + pass + if discount > 0: + try: + await compute_client.set_group_group_ratio(group, "default", 1 - discount / 100) + except Exception: # noqa: BLE001 + pass + return {"ok": True} + + # ---------------- 智能体 ---------------- async def get_agent(tenant_id: str) -> dict: diff --git a/app/services/compute_client.py b/app/services/compute_client.py index a49846a..a9a6681 100644 --- a/app/services/compute_client.py +++ b/app/services/compute_client.py @@ -197,6 +197,46 @@ async def adjust_user_quota(engine_user_id: int, value: int, mode: str = "add") ) +async def set_user_group_by_username(username: str, group: str) -> dict: + """把引擎用户划到某个用户组(企业折扣凭 User.Group 触发组倍率)。 + + 走管理透传 ``PUT /api/user``。best-effort:失败返回 {status,body},由调用方决定是否阻断。 + """ + return await proxy("PUT", "/user", json_body={"username": username, "group": group}) + + +async def grant_user_quota_by_username(username: str, value: int) -> dict: + """按 username 给引擎用户充值(先经管理面解析引擎用户 id,再 add_quota)。 + + 解析:``GET /api/user/search?username=``(new-api 返回 data.id)。best-effort。 + """ + res = await proxy("GET", "/user/search", params={"username": username}) + body = res.get("body") or {} + if not isinstance(body, dict) or not body.get("success", True): + return {"ok": False, "message": "未找到引擎用户"} + data = body.get("data") + uid = None + if isinstance(data, dict): + uid = data.get("id") + elif isinstance(data, list) and data: + uid = data[0].get("id") + if not uid: + return {"ok": False, "message": "未解析到引擎用户 id"} + return await adjust_user_quota(int(uid), int(value), "add") + + +async def set_group_group_ratio(user_group: str, using_group: str, ratio: float) -> dict: + """设置「用户组×模型组」倍率(= 1 - discount%),引擎计费时按此折算模型价。 + + 走管理透传 ``PUT /api/setting``(key=group_ratio_setting)。best-effort。 + """ + return await proxy( + "PUT", "/setting", + json_body={"key": "group_ratio_setting", + "value": {"group_group_ratio": {user_group: {using_group: ratio}}}}, + ) + + async def sync_user_enabled(username: str, enabled: bool) -> dict: """按 username 同步引擎用户启用/禁用(平台用户生命周期自动同步)。""" return await _request(