# -*- 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 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"]) @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/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) 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} @router.post("/compute/tokens", summary="创建算力令牌") async def opc_compute_tokens_create(user: dict = Depends(require_roles("opc_member"))): """为当前用户签发一枚消费令牌(供应商自动对接 PineAgents)。""" try: token = await compute_client.issue_user_token(user.get("username"), name="OPC 端令牌") 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: 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"])}