Files
server-core/app/api/routers/rbac_opc.py
T
Pine 539282f18f security: 平台中继令牌不对外展示、不允许删除
平台中继令牌由系统自动创建(name='平台中继令牌'),桌面端智能体
调用模型时使用此令牌。为防止用户复制外泄配额或误删导致桌面端
无法调用模型:

1. GET /opc/compute/tokens:过滤掉 name='平台中继令牌' 的令牌,
   不返回给前端展示
2. DELETE /opc/compute/tokens/{id}:删除前校验,若目标令牌是
   平台中继令牌,返回 403 拒绝删除
3. 定义常量 PLATFORM_RELAY_TOKEN_NAME 统一管理
2026-09-08 00:08:27 +08:00

683 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""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"])}