feat(park-company): 园区企业→成员 + 企业算力(折扣%/配额,只增不减) 后端

- 迁移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 <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-26 19:57:37 +08:00
parent ac2df72b21
commit 8697119aaa
7 changed files with 346 additions and 6 deletions
+35
View File
@@ -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")
+47 -1
View File
@@ -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}
+7
View File
@@ -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="")
+1
View File
@@ -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,
+79
View File
@@ -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}
+137 -5
View File
@@ -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:
+40
View File
@@ -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(