539282f18f
平台中继令牌由系统自动创建(name='平台中继令牌'),桌面端智能体
调用模型时使用此令牌。为防止用户复制外泄配额或误删导致桌面端
无法调用模型:
1. GET /opc/compute/tokens:过滤掉 name='平台中继令牌' 的令牌,
不返回给前端展示
2. DELETE /opc/compute/tokens/{id}:删除前校验,若目标令牌是
平台中继令牌,返回 403 拒绝删除
3. 定义常量 PLATFORM_RELAY_TOKEN_NAME 统一管理
683 lines
29 KiB
Python
683 lines
29 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""OPC 超级个体端端点(工作台聚合数据)。
|
||
|
||
所有数据均由服务端统一总库提供,前端不落地任何演示数据。
|
||
当前角色要求 opc_member;后续各页面端点在此扩展。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||
from pydantic import BaseModel
|
||
|
||
from ..dependencies import get_db, optional_current_user
|
||
from ..schemas.opc import ProfileUpdate, FinanceRecordCreate, OpcCertApplyRequest, ParkTransferApplyRequest
|
||
from ...rbac import require_roles, write_audit
|
||
from ...infrastructure.repositories import Database, new_id, utcnow_iso
|
||
from ...infrastructure.models import FinanceRecord, ContentComment
|
||
from ... import config
|
||
from ...services import compute_client, compute_catalog
|
||
|
||
router = APIRouter(prefix="/opc", tags=["opc"])
|
||
PLATFORM_RELAY_TOKEN_NAME = "平台中继令牌"
|
||
# 平台中继令牌:系统自动创建,桌面端智能体调用模型时使用,不对外展示、不允许删除
|
||
|
||
|
||
|
||
@router.get("/dashboard", summary="OPC 工作台总览")
|
||
async def opc_dashboard(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""返回当前 OPC 用户的工作台聚合数据(统计/进行中任务/智能体建议/月度收入/最新消息)。"""
|
||
from ...services.dashboard_service import DashboardService
|
||
|
||
return await DashboardService(db).opc_dashboard(user["id"])
|
||
|
||
|
||
# ── OPC 子页面(全部由服务端提供标准数据)─────────────────────────────────
|
||
|
||
@router.get("/agent", summary="OPC 智能体助手")
|
||
async def opc_agent(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
agents = await db.agents.get_by_user(user["id"], port=user.get("port") or "opc")
|
||
default = next((a for a in agents if a.get("id", "").startswith("pine_agents_official_001")), None)
|
||
return {
|
||
"agent": {
|
||
"name": (default or {}).get("name", "小云"),
|
||
"desc": (default or {}).get("description", "您的智能体助手,可处理创业、政策、财税等事务"),
|
||
"port": user.get("port") or "opc",
|
||
},
|
||
"quick_actions": [
|
||
{"title": "查询政策", "desc": "为您匹配最新创业政策"},
|
||
{"title": "发布能力", "desc": "快速发布您的服务能力"},
|
||
{"title": "财务助手", "desc": "查看收入与支出"},
|
||
],
|
||
"chat_path": "/chat",
|
||
}
|
||
|
||
|
||
@router.get("/my-tasks", summary="我的任务(真实接单/投标聚合)")
|
||
async def opc_my_tasks(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""聚合该用户真实承接的任务(task_claims)与投标(bids),附任务信息。
|
||
|
||
2026-09 增量合并:不再返回 opc_tasks 演示看板,改为真实任务系统数据。
|
||
"""
|
||
claims = await db.task_claims.list_by_claimer(user["id"])
|
||
bids = await db.bids.list_by_opc(user["id"])
|
||
task_cache: dict[str, dict] = {}
|
||
for c in claims + bids:
|
||
tid = c.get("task_id") or c.get("taskId") or ""
|
||
if tid and tid not in task_cache:
|
||
t = await db.tasks.get(tid)
|
||
if t:
|
||
task_cache[tid] = t
|
||
items = []
|
||
for c in claims:
|
||
t = task_cache.get(c.get("task_id") or "")
|
||
if not t:
|
||
continue
|
||
items.append({
|
||
"id": t["id"], "claimId": c["id"], "title": t.get("title", ""),
|
||
"taskCode": t.get("task_code", ""), "mode": t.get("mode", ""),
|
||
"status": t.get("status", ""), "claimSource": c.get("claim_source", ""),
|
||
"claimStatus": c.get("status", ""),
|
||
"budgetMin": t.get("budget_min", 0), "budgetMax": t.get("budget_max", 0),
|
||
"deadline": t.get("deadline", ""), "createdAt": t.get("created_at", ""),
|
||
})
|
||
for b in bids:
|
||
t = task_cache.get(b.get("task_id") or "")
|
||
if not t:
|
||
continue
|
||
items.append({
|
||
"id": t["id"], "bidId": b["id"], "title": t.get("title", ""),
|
||
"taskCode": t.get("task_code", ""), "mode": t.get("mode", ""),
|
||
"status": t.get("status", ""), "claimSource": "bid",
|
||
"claimStatus": b.get("status", ""), "quote": b.get("quote", 0),
|
||
"plan": b.get("plan", ""),
|
||
"budgetMin": t.get("budget_min", 0), "budgetMax": t.get("budget_max", 0),
|
||
"deadline": t.get("deadline", ""), "createdAt": t.get("created_at", ""),
|
||
})
|
||
return {"items": items}
|
||
|
||
|
||
@router.get("/services", summary="服务市场(在营服务商)")
|
||
async def opc_services(
|
||
db: Database = Depends(get_db),
|
||
_u: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
return {"items": await db.providers.list(status="active")}
|
||
|
||
|
||
@router.get("/policy", summary="政策列表(已发布政策)")
|
||
async def opc_policy(
|
||
db: Database = Depends(get_db),
|
||
_u: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
return {"items": await db.content.list(ctype="policy", status="published", scheduled_ready=True)}
|
||
|
||
|
||
def _resolve_content_media(item: dict) -> dict:
|
||
"""C 端出口:媒体字段(对象路径)统一补全为可访问 URL(CDN 鉴权直链 / 站点绝对路径)。"""
|
||
from ...infrastructure.oss import resolve_url
|
||
for k in ("publisher_avatar", "cover", "cover_small", "video", "video_cover"):
|
||
item[k] = resolve_url(item.get(k, ""))
|
||
item["images"] = [resolve_url(u) for u in (item.get("images") or [])]
|
||
return item
|
||
|
||
|
||
@router.get("/content", summary="资讯中心(按类别,已发布)")
|
||
async def opc_content(
|
||
type: str = "news",
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""C 端资讯:type ∈ policy/news/skill/dynamic,仅 published + 公开(is_public)。公开浏览。"""
|
||
items = await db.content.list(ctype=type, status="published", public_only=True, scheduled_ready=True)
|
||
return {"items": [_resolve_content_media(it) for it in items]}
|
||
|
||
|
||
@router.get("/content/{content_id}", summary="资讯详情")
|
||
async def opc_content_detail(
|
||
content_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict | None = Depends(optional_current_user),
|
||
):
|
||
item = await db.content.get(content_id)
|
||
if item is None or item.get("status") != "published" or not item.get("is_public"):
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
# 阅读数自增(每次详情浏览 +1)
|
||
if item.get("status") == "published":
|
||
await db.content.incr_read_count(content_id)
|
||
item["read_count"] = (item.get("read_count") or 0) + 1
|
||
item = _resolve_content_media(item)
|
||
# 当前登录用户是否已点赞(未登录 false)
|
||
item["liked_by_me"] = await db.content.is_liked(content_id, (user or {}).get("id", ""))
|
||
return item
|
||
|
||
|
||
@router.post("/content/{content_id}/like", summary="点赞/取消点赞(登录,一人一赞)")
|
||
async def opc_content_like(
|
||
content_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
item = await db.content.get(content_id)
|
||
if item is None:
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
return await db.content.toggle_like(content_id, user["id"])
|
||
|
||
|
||
@router.post("/content/{content_id}/share", summary="分享计数 +1(可重复分享)")
|
||
async def opc_content_share(
|
||
content_id: str,
|
||
db: Database = Depends(get_db),
|
||
):
|
||
"""分享落库:web 复制链接 / 小程序转发成功后调用,share_count +1。公开计数。"""
|
||
item = await db.content.get(content_id)
|
||
if item is None or item.get("status") != "published" or not item.get("is_public"):
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
return await db.content.add_share(content_id)
|
||
|
||
|
||
@router.get("/content/{content_id}/comments", summary="资讯留言列表(微信式嵌套)")
|
||
async def opc_content_comments(
|
||
content_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict | None = Depends(optional_current_user),
|
||
):
|
||
from ...infrastructure.oss import resolve_url
|
||
uid = (user or {}).get("id", "")
|
||
items = await db.content.content_comments(content_id, nested=True)
|
||
for c in items:
|
||
c["avatar"] = resolve_url(c.get("avatar", ""))
|
||
c["liked_by_me"] = await db.content.is_comment_liked(c["id"], uid) if uid else False
|
||
for rp in c.get("replies", []):
|
||
rp["avatar"] = resolve_url(rp.get("avatar", ""))
|
||
rp["liked_by_me"] = await db.content.is_comment_liked(rp["id"], uid) if uid else False
|
||
return {"items": items}
|
||
|
||
|
||
class ContentCommentBody(BaseModel):
|
||
content: str
|
||
reply_to: str = "" # 回复目标评论 id(空=顶层留言)
|
||
|
||
|
||
@router.post("/content/{content_id}/comments", summary="发表留言/回复")
|
||
async def opc_content_add_comment(
|
||
content_id: str,
|
||
body: ContentCommentBody,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
if not body.content.strip():
|
||
raise HTTPException(status_code=400, detail="留言不能为空")
|
||
item = await db.content.get(content_id)
|
||
if item is None or item.get("status") != "published":
|
||
raise HTTPException(status_code=404, detail="内容不存在")
|
||
# 取当前用户资料(昵称/头像)用于留言展示
|
||
me = await db.users.get_by_id(user["id"]) or {}
|
||
actor = {"id": user["id"], "username": user.get("username", ""),
|
||
"nickname": (me.get("nickname") or user.get("username", "")),
|
||
"avatar": me.get("avatar", "")}
|
||
comment = await db.content.add_content_comment(content_id, actor, body.content.strip(),
|
||
reply_to=body.reply_to.strip())
|
||
if comment:
|
||
from ...infrastructure.oss import resolve_url
|
||
comment["avatar"] = resolve_url(comment.get("avatar", ""))
|
||
return {"ok": True, "comment": comment}
|
||
|
||
|
||
@router.post("/comments/{comment_id}/like", summary="评论点赞/取消(登录,一人一赞)")
|
||
async def opc_comment_like(
|
||
comment_id: str,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
c = await db.session.get(ContentComment, comment_id)
|
||
if c is None:
|
||
raise HTTPException(status_code=404, detail="留言不存在")
|
||
return await db.content.toggle_comment_like(comment_id, user["id"])
|
||
|
||
|
||
@router.get("/finance", summary="财务流水")
|
||
async def opc_finance(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
uid = user["id"]
|
||
return {
|
||
"records": await db.finance.list_by_user(uid),
|
||
"total_income": await db.finance.total_income(uid),
|
||
}
|
||
|
||
|
||
@router.get("/messages", summary="站内消息")
|
||
async def opc_messages(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
return {"items": await db.messages.list_by_user(user["id"])}
|
||
|
||
|
||
@router.get("/profile", summary="个人资料")
|
||
async def opc_profile(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
profile = await db.users.to_profile(user)
|
||
profile["opc_profile"] = await db.opc_profiles.get(user["id"])
|
||
return profile
|
||
|
||
|
||
|
||
@router.put("/profile", summary="OPC 基础资料编辑")
|
||
async def opc_update_profile(
|
||
req: ProfileUpdate,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
profile_updates = {k: v for k, v in req.model_dump(exclude_none=True).items()
|
||
if k in ("nickname", "account", "company", "room")}
|
||
if profile_updates:
|
||
await db.users.update_profile(actor["id"], profile_updates)
|
||
if req.credit_score is not None:
|
||
await db.opc_profiles.upsert(actor["id"], req.credit_score)
|
||
await write_audit(db, action="profile.update", resource="user", resource_id=actor["id"],
|
||
user=actor, request=request)
|
||
fresh_user = await db.users.get_by_id(actor["id"])
|
||
return await opc_profile(db, fresh_user)
|
||
|
||
|
||
@router.post("/finance/record", summary="OPC 记一笔收支")
|
||
async def opc_add_finance(
|
||
req: FinanceRecordCreate,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
actor: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
from ...services.finance_service import FinanceService
|
||
|
||
record = await FinanceService(db).add_record(
|
||
user_id=actor["id"], category=req.category, amount=req.amount,
|
||
date=req.date or utcnow_iso()[:10], note=req.note,
|
||
)
|
||
await write_audit(db, action="finance.add", resource="finance", resource_id=record["id"],
|
||
user=actor, request=request)
|
||
return record
|
||
|
||
|
||
@router.get("/tax", summary="OPC 税务申报")
|
||
async def opc_tax(
|
||
db: Database = Depends(get_db),
|
||
_u: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
return await db.portal_pages.get("opc", "tax") or {"items": []}
|
||
|
||
|
||
@router.get("/affairs", summary="OPC 工商/政务办事")
|
||
async def opc_affairs(
|
||
db: Database = Depends(get_db),
|
||
_u: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
return await db.portal_pages.get("opc", "affairs") or {"items": []}
|
||
|
||
|
||
# ── 算力中心(用户端,供应商自动对接 PineAgents,令牌自服务)─────────────────
|
||
@router.get("/compute/base", summary="算力基础 API 地址")
|
||
async def opc_compute_base(
|
||
request: Request,
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""用户接入地址:经 server-core /v1 中继(OpenAI 兼容)。优先用公网配置,回落请求 host。"""
|
||
base = (config.COMPUTE_PUBLIC_BASE or str(request.base_url)).rstrip("/")
|
||
# 反代/配置落成 http 时对外一律升级 https(本地回环除外),否则客户端 https 页面/证书校验不可用
|
||
if "://" in base:
|
||
scheme, rest = base.split("://", 1)
|
||
host = rest.split("/", 1)[0]
|
||
if scheme == "http" and not (host.startswith(("localhost", "127.0.0.1", "192.168.", "10.", "172."))):
|
||
base = f"https://{rest}"
|
||
return {
|
||
"base_url": f"{base}/v1",
|
||
"relay_path": "/v1/chat/completions",
|
||
"models_path": "/v1/models",
|
||
"username": user.get("username"),
|
||
"hint": "将 base_url 与下方令牌填入 OpenAI 兼容客户端(如 base_url/api_key)即可调用本平台模型",
|
||
}
|
||
|
||
|
||
@router.get("/compute/models", summary="可用模型(算力中心,admin 新增)")
|
||
async def opc_compute_models(_u: dict = Depends(require_roles("opc_member"))):
|
||
return {"items": await compute_catalog.models()}
|
||
|
||
|
||
@router.get("/compute/prices", summary="模型价目(admin 模型)")
|
||
async def opc_compute_prices(_u: dict = Depends(require_roles("opc_member"))):
|
||
return {"items": await compute_catalog.prices()}
|
||
|
||
|
||
@router.get("/compute/usage", summary="我的用量(本月按模型)")
|
||
async def opc_compute_usage(user: dict = Depends(require_roles("opc_member"))):
|
||
try:
|
||
data = await compute_client.user_usage(user.get("username"))
|
||
except compute_client.ComputeError as exc:
|
||
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
|
||
return data
|
||
|
||
|
||
@router.get("/compute/usage/logs", summary="我的消耗明细(分页)")
|
||
async def opc_compute_usage_logs(
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""消耗流水明细,支持分页。"""
|
||
try:
|
||
data = await compute_client.user_logs(user.get("username"), page=page, page_size=page_size)
|
||
except compute_client.ComputeError as exc:
|
||
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
|
||
|
||
items = data if isinstance(data, list) else data.get("items", [])
|
||
total = data.get("total", len(items)) if isinstance(data, dict) else len(items)
|
||
|
||
# 统一字段格式(snake_case → 前端期望的格式)
|
||
def _norm_time(v):
|
||
"""ISO 字符串原样返回;毫秒时间戳转 'YYYY-MM-DD HH:MM:SS'(兼容算力端旧格式)。"""
|
||
if not v:
|
||
return ""
|
||
if isinstance(v, (int, float)):
|
||
try:
|
||
from datetime import datetime as _dt
|
||
return _dt.fromtimestamp(float(v) / 1000).strftime("%Y-%m-%d %H:%M:%S")
|
||
except Exception: # noqa: BLE001
|
||
return ""
|
||
return str(v)
|
||
|
||
out = []
|
||
for it in items or []:
|
||
it = dict(it)
|
||
raw_token = str(it.get("token_name") or "")
|
||
# 消耗渠道:平台中继令牌/无令牌 = 桌面端;自建令牌 = Key · 名称
|
||
channel = "桌面端" if (not raw_token or raw_token == "平台中继令牌") else f"Key · {raw_token}"
|
||
out.append({
|
||
"id": it.get("id") or it.get("log_id") or "",
|
||
"time": _norm_time(it.get("created_at") or it.get("time") or it.get("created_time") or ""),
|
||
"model": it.get("model_name") or it.get("model") or "",
|
||
"type": it.get("type") or it.get("mode") or "chat",
|
||
"input_tokens": it.get("input_tokens") or it.get("prompt_tokens") or 0,
|
||
"output_tokens": it.get("output_tokens") or it.get("completion_tokens") or 0,
|
||
"cost": it.get("cost") or it.get("cost_quota") or 0,
|
||
"status": it.get("status") or "success",
|
||
"channel": channel,
|
||
})
|
||
|
||
return {"items": out, "total": total, "page": page, "page_size": page_size}
|
||
|
||
|
||
@router.get("/compute/usage/trend", summary="消耗趋势统计(近7天/模型分布/时段分布)")
|
||
async def opc_compute_usage_trend(
|
||
days: int = 7,
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""消耗趋势统计:近N天消耗趋势、模型分布、时段分布。"""
|
||
from collections import defaultdict
|
||
from datetime import datetime, timedelta
|
||
|
||
try:
|
||
# 获取最近的消耗日志(最多取 500 条用于统计)
|
||
data = await compute_client.user_logs(user.get("username"), page=1, page_size=500)
|
||
except compute_client.ComputeError as exc:
|
||
# 引擎对接失败时返回空数据,不报错
|
||
data = {"items": []}
|
||
|
||
items = data if isinstance(data, list) else data.get("items", [])
|
||
|
||
# 近N天消耗趋势
|
||
now = datetime.now()
|
||
trend_map = {}
|
||
for i in range(days):
|
||
d = (now - timedelta(days=days - 1 - i)).strftime("%m-%d")
|
||
trend_map[d] = {"date": d, "cost": 0, "tokens": 0}
|
||
|
||
# 模型分布
|
||
model_dist = defaultdict(lambda: {"name": "", "value": 0, "cost": 0})
|
||
|
||
# 时段分布(24小时)
|
||
hourly_dist = [{"hour": f"{h:02d}", "cost": 0} for h in range(24)]
|
||
|
||
total_cost = 0
|
||
total_tokens = 0
|
||
|
||
for it in items or []:
|
||
it = dict(it)
|
||
# 解析时间
|
||
time_str = it.get("created_at") or it.get("time") or it.get("created_time") or ""
|
||
cost = float(it.get("cost") or it.get("cost_quota") or 0)
|
||
input_tokens = int(it.get("input_tokens") or it.get("prompt_tokens") or 0)
|
||
output_tokens = int(it.get("output_tokens") or it.get("completion_tokens") or 0)
|
||
tokens = input_tokens + output_tokens
|
||
model_name = it.get("model_name") or it.get("model") or "unknown"
|
||
|
||
total_cost += cost
|
||
total_tokens += tokens
|
||
|
||
# 趋势统计
|
||
if time_str:
|
||
try:
|
||
dt = datetime.fromisoformat(time_str.replace("Z", "+00:00")) if "T" in time_str else datetime.strptime(time_str[:10], "%Y-%m-%d")
|
||
date_key = dt.strftime("%m-%d")
|
||
if date_key in trend_map:
|
||
trend_map[date_key]["cost"] += cost
|
||
trend_map[date_key]["tokens"] += tokens
|
||
|
||
# 时段统计
|
||
hour = dt.hour
|
||
hourly_dist[hour]["cost"] += cost
|
||
except (ValueError, TypeError):
|
||
pass
|
||
|
||
# 模型分布统计
|
||
model_dist[model_name]["name"] = model_name
|
||
model_dist[model_name]["value"] += 1
|
||
model_dist[model_name]["cost"] += cost
|
||
|
||
# 模型分布取 Top 5,其余归为"其他"
|
||
model_list = sorted(model_dist.values(), key=lambda x: x["value"], reverse=True)
|
||
if len(model_list) > 5:
|
||
top5 = model_list[:5]
|
||
others = model_list[5:]
|
||
other_value = sum(x["value"] for x in others)
|
||
other_cost = sum(x["cost"] for x in others)
|
||
top5.append({"name": "其他", "value": other_value, "cost": other_cost})
|
||
model_list = top5
|
||
|
||
# 模型分布颜色
|
||
colors = ["#6284ff", "#8f7bff", "#3fb68b", "#e8830c", "#e0526e", "#38a8e0"]
|
||
for i, m in enumerate(model_list):
|
||
m["color"] = colors[i % len(colors)]
|
||
|
||
return {
|
||
"trend": list(trend_map.values()),
|
||
"model_distribution": model_list,
|
||
"hourly_distribution": hourly_dist,
|
||
"summary": {
|
||
"total_cost": total_cost,
|
||
"total_tokens": total_tokens,
|
||
"total_requests": len(items or []),
|
||
"days": days,
|
||
},
|
||
}
|
||
|
||
|
||
@router.get("/compute/balance", summary="我的算力余额")
|
||
async def opc_compute_balance(user: dict = Depends(require_roles("opc_member"))):
|
||
try:
|
||
data = await compute_client.user_balance(user.get("username"))
|
||
except compute_client.ComputeError as exc:
|
||
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
|
||
return data
|
||
|
||
|
||
@router.get("/compute/tokens", summary="我的算力令牌")
|
||
async def opc_compute_tokens(user: dict = Depends(require_roles("opc_member"))):
|
||
"""列出当前用户的引擎消费令牌。key 统一补 sk- 前缀(OpenAI 客户端口径)。"""
|
||
try:
|
||
data = await compute_client.list_user_tokens(user.get("username"))
|
||
except compute_client.ComputeError as exc:
|
||
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
|
||
items = data if isinstance(data, list) else data.get("items", [])
|
||
out = []
|
||
for it in items or []:
|
||
it = dict(it)
|
||
# 平台中继令牌由系统自动创建,桌面端智能体调用模型时使用,不对外展示、不允许删除
|
||
if str(it.get("name") or "") == PLATFORM_RELAY_TOKEN_NAME:
|
||
continue
|
||
key = str(it.get("key") or it.get("token") or "")
|
||
if key and not key.startswith("sk-"):
|
||
it["key"] = f"sk-{key}"
|
||
out.append(it)
|
||
return {"items": out}
|
||
|
||
|
||
class OpcTokenCreateRequest(BaseModel):
|
||
"""创建算力令牌请求:name 为空时按用户令牌数自动编号(令牌1/令牌2/…)。"""
|
||
name: str = ""
|
||
|
||
|
||
@router.post("/compute/tokens", summary="创建算力令牌")
|
||
async def opc_compute_tokens_create(
|
||
body: OpcTokenCreateRequest | None = None,
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""为当前用户签发一枚消费令牌(供应商自动对接 PineAgents)。"""
|
||
try:
|
||
token = await compute_client.issue_user_token(
|
||
user.get("username"), name=(body.name if body else "")
|
||
)
|
||
except compute_client.ComputeError as exc:
|
||
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
|
||
token = dict(token or {})
|
||
key = str(token.get("key") or token.get("token") or "")
|
||
if key and not key.startswith("sk-"):
|
||
token["key"] = f"sk-{key}"
|
||
return token
|
||
|
||
|
||
@router.delete("/compute/tokens/{token_id}", summary="删除算力令牌")
|
||
async def opc_compute_tokens_delete(token_id: int, user: dict = Depends(require_roles("opc_member"))):
|
||
# 平台中继令牌不允许删除(桌面端智能体依赖此令牌调用模型)
|
||
try:
|
||
existing = await compute_client.list_user_tokens(user.get("username"))
|
||
except compute_client.ComputeError as exc:
|
||
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
|
||
existing_items = existing if isinstance(existing, list) else existing.get("items", [])
|
||
for it in existing_items or []:
|
||
if int(dict(it).get("id") or 0) == token_id:
|
||
if str(dict(it).get("name") or "") == PLATFORM_RELAY_TOKEN_NAME:
|
||
raise HTTPException(status_code=403, detail="平台中继令牌由系统管理,不允许删除")
|
||
break
|
||
try:
|
||
await compute_client.delete_token(token_id)
|
||
except compute_client.ComputeError as exc:
|
||
raise HTTPException(status_code=502, detail=f"算力引擎对接失败: {exc}") from exc
|
||
return {"ok": True, "token_id": token_id}
|
||
|
||
|
||
# ── OPC 认证 ─────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/certification/apply", summary="提交 OPC 认证申请")
|
||
async def opc_cert_apply(
|
||
req: OpcCertApplyRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""当前 OPC 提交认证资料 → 状态置 pending,待运营方审核。"""
|
||
me = await db.users.get_by_id(user["id"])
|
||
if me is None:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
if me.get("certification_status") == "certified":
|
||
raise HTTPException(status_code=400, detail="当前账号已是认证OPC,无需重复申请")
|
||
if me.get("certification_status") == "pending":
|
||
raise HTTPException(status_code=400, detail="认证申请审核中,请勿重复提交")
|
||
applied = await db.opc_certifications.by_user(user["id"])
|
||
if applied and applied.get("status") in ("pending", "reviewing"):
|
||
raise HTTPException(status_code=400, detail="已有待审核的认证申请,请勿重复提交")
|
||
cert = await db.opc_certifications.create({
|
||
"real_name": req.real_name, "gender": req.gender, "address": req.address,
|
||
"industry": req.industry, "ability": req.ability, "phone": req.phone,
|
||
"docs_json": req.docs_json or "{}",
|
||
}, user["id"], me.get("username", ""))
|
||
await write_audit(
|
||
action="opc.cert_apply", resource="opc_certification", resource_id=cert["id"],
|
||
detail=f"real_name={req.real_name} industry={req.industry}",
|
||
user=user, request=request,
|
||
)
|
||
return {"ok": True, "certification": cert}
|
||
|
||
|
||
@router.get("/certification/mine", summary="我的 OPC 认证状态")
|
||
async def opc_cert_mine(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
me = await db.users.get_by_id(user["id"])
|
||
cert = await db.opc_certifications.by_user(user["id"])
|
||
return {
|
||
"certification_status": (me or {}).get("certification_status", "uncertified"),
|
||
"certification": cert,
|
||
}
|
||
|
||
|
||
# ── OPC 转园 ─────────────────────────────────────────────────────────────
|
||
|
||
@router.post("/park-transfer/apply", summary="发起 OPC 转园申请")
|
||
async def opc_park_transfer_apply(
|
||
req: ParkTransferApplyRequest,
|
||
request: Request,
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
"""园区 OPC 申请转入目标园区(原园区自动取当前 park 归属,经园区/平台审核)。"""
|
||
me = await db.users.get_by_id(user["id"])
|
||
if me is None:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
if me.get("affiliation") != "park" or not me.get("park_id"):
|
||
raise HTTPException(status_code=400, detail="仅园区 OPC 可发起转园(需先归属园区)")
|
||
if not req.to_park_id:
|
||
raise HTTPException(status_code=400, detail="请选择目标园区")
|
||
if req.to_park_id == me.get("park_id"):
|
||
raise HTTPException(status_code=400, detail="目标园区与当前园区相同")
|
||
existing = await db.park_transfers.by_user(user["id"])
|
||
if existing and existing.get("status") in ("pending", "reviewing"):
|
||
raise HTTPException(status_code=400, detail="已有待审核的转园申请,请勿重复提交")
|
||
# 两级审核状态机:转出园区无管理员时第一级自动通过,直接进第二级
|
||
from ...domain import park_transfer as pt_rules
|
||
from app.park import tenants as tnt
|
||
from_tenant = await tnt.get_tenant(me.get("park_id", ""))
|
||
transfer = await db.park_transfers.create({
|
||
"to_park_id": req.to_park_id, "to_park_name": req.to_park_name,
|
||
"reason": req.reason,
|
||
"from_park_id": me.get("park_id", ""), "from_park_name": me.get("park_name", ""),
|
||
"stage": pt_rules.initial_stage(from_tenant),
|
||
"initiator_role": "user", "target_kind": "user",
|
||
}, user["id"], me.get("username", ""))
|
||
await write_audit(
|
||
action="opc.park_transfer_apply", resource="park_transfer", resource_id=transfer["id"],
|
||
detail=f"from={me.get('park_name','')} to={req.to_park_name}",
|
||
user=user, request=request,
|
||
)
|
||
return {"ok": True, "transfer": transfer}
|
||
|
||
|
||
@router.get("/park-transfer/mine", summary="我的转园申请")
|
||
async def opc_park_transfer_mine(
|
||
db: Database = Depends(get_db),
|
||
user: dict = Depends(require_roles("opc_member")),
|
||
):
|
||
return {"transfer": await db.park_transfers.by_user(user["id"])}
|