Files
server-core/app/api/routers/rbac_hall.py
T
Pine e831fdd34e feat: 内容审核体系(0070)— 违规内容库/举报表/IP采集/统一审核服务/审核API
- 迁移0070:content_moderation_records(违规库,快照+发布者+IP+原因+处置人)、
  content_reports(举报队列);各内容表加 ip 列,dm_messages 加 hidden,
  community_comments/ratings 加 status,users 加 profile_hidden/original_nickname
- moderation_service:hide/restore/delete(物理删除留档)/list/archive/report/
  handle_report/scan + 申诉通知;用户资料隐藏自动脱敏昵称
- rbac_moderation:/admin/moderation/{type} 列表、hide/restore/delete、
  violations 违规库、reports 举报处理、scan 巡查;/content/report 用户举报入口
- IP 采集:任务/岗位/服务/社区帖子/评论/私信/评价/主页提交创建端点写入发布者 IP
- 前台可见性:社区评论/私信查询过滤 hidden
2026-09-07 15:07:43 +08:00

1545 lines
75 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 -*-
"""接口层 · 大厅(任务/人才/岗位/服务)+ 社区(线上/实体载体)。
设计:design/云超服平台/大厅与社区升级设计方案.md §4。
- 公共读:游客可访问(optional_current_user),登录后附 can_accept / 已报名标记。
- 写接口:按 capabilities 叠加制角色判定(opc_member 基础能力 + enterprise/carrier/operator 叠加)。
- 审核类写接口(任务/岗位/服务上下架)走 operator 能力;admin 端管理页在 rbac_admin 扩展。
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from ..dependencies import Database, get_current_user, get_db, get_user_primary_org, optional_current_user
from ..schemas.opc import TaskClaimRequest
from ...infrastructure.models import ParkTenant
from ...infrastructure.repositories import utcnow_iso
from ...rbac import write_audit
router = APIRouter(prefix="/hall", tags=["hall"])
def require_cap(*caps: str):
"""按叠加制能力集合判定(capabilitiesopc_member/enterprise/carrier/operator)。"""
from fastapi import Depends as _Depends
def dep(user: dict = _Depends(get_current_user)) -> dict:
if not (set(caps) & set(user.get("capabilities") or [])):
raise HTTPException(status_code=403, detail="无权限执行该操作")
return user
return dep
def _publish_status(user: dict) -> str:
"""operator 直接发布,其余角色进审核(pending → 运营端通过后 published)。"""
return "published" if "operator" in (user.get("capabilities") or []) else "pending"
# ===========================================================================
# 大厅 Hub
# ===========================================================================
@router.get("/overview", summary="大厅总览(四厅统计)")
async def hall_overview(db: Database = Depends(get_db)):
from ...infrastructure.cache import cache as redis_cache
cache_key = "hall:overview"
cached = await redis_cache.get_json(cache_key)
if cached is not None:
return cached
tasks = [t for t in await db.tasks.list_published() if t.get("status") == "published" and t.get("c_visible")]
result = {
"tasks": len(tasks),
"grab": sum(1 for t in tasks if t.get("mode") == "grab"),
"bid": sum(1 for t in tasks if t.get("mode") == "bid"),
"register": sum(1 for t in tasks if t.get("mode") == "register"),
"talents": len(await db.hall_talents.list()),
"jobs": len(await db.hall_jobs.list(status="published")),
"services": len(await db.hall_services.list(status="published")),
}
import asyncio
asyncio.create_task(redis_cache.set_json(cache_key, result, ttl=30))
return result
# ===========================================================================
# 任务大厅
# ===========================================================================
@router.get("/tasks", summary="任务大厅列表(公开;可按模式/分类/关键字筛选)")
async def hall_tasks(
mode: str = "",
category: str = "",
q: str = "",
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
from ...services.task_service import TaskService
from ...infrastructure.cache import cache as redis_cache
import hashlib
# 构建缓存 key:含用户ID + 筛选条件
uid = user.get("id") if user else "anon"
filter_hash = hashlib.md5(f"{mode}:{category}:{q}".encode()).hexdigest()[:10]
cache_key = f"hall:tasks:{uid}:{filter_hash}"
# 尝试读缓存
cached = await redis_cache.get_json(cache_key)
if cached is not None:
return cached
items = [t for t in await db.tasks.list_published()
if t.get("status") == "published" and t.get("c_visible")]
if mode:
items = [t for t in items if t.get("mode") == mode]
if category:
items = [t for t in items if t.get("category") == category]
if q:
kw = q.lower()
items = [t for t in items
if kw in (t.get("title") or "").lower() or kw in (t.get("tags") or "").lower()
or kw in (t.get("publisher_name") or "").lower()]
items.sort(key=lambda t: -(t.get("display_priority") or 0))
if user:
svc = TaskService(db)
# 性能优化:预取一次用户档案,避免每个任务都重复查询(N+1 问题)
actor_profile = await svc._actor_profile(user)
# 并发判断所有任务的接单资格,而不是串行循环
import asyncio
accept_results = await asyncio.gather(*[
svc.can_accept(it, user, actor_profile=actor_profile) for it in items
])
for it, acc in zip(items, accept_results):
it["can_accept"] = acc["ok"]
it["accept_reasons"] = acc["reasons"]
result = {"items": items}
# 写缓存(TTL 8 秒,平衡实时性和性能)
import asyncio as _asyncio
_asyncio.create_task(redis_cache.set_json(cache_key, result, ttl=8))
return result
# 字面路径必须在参数化路径之前注册,否则 /tasks/mine 会被 /tasks/{task_id} 抢先匹配
@router.get("/tasks/mine", summary="我发布的任务(含接单/投标数)")
async def hall_my_tasks(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
items = [t for t in await db.tasks.list(status="") if t.get("publisher_id") == user["id"]]
out = []
for t in items:
claims = await db.task_claims.list_by_task(t["id"])
bids = await db.bids.list_for_task(t["id"])
out.append({**t,
"claimCount": sum(1 for c in claims if c.get("status") in ("claimed", "doing", "assigned")),
"registerCount": sum(1 for c in claims if c.get("claim_source") == "register" and c.get("status") == "registered"),
"bidCount": sum(1 for b in bids if b.get("status") == "submitted")})
return {"items": out}
@router.get("/tasks/{task_id}", summary="任务详情(公开;登录附资格与我的接单/投标/报名状态)")
async def hall_task_detail(
task_id: str,
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
from ...services.task_service import TaskService
item = await db.tasks.get(task_id)
if item is None or not item.get("c_visible"):
raise HTTPException(status_code=404, detail="任务不存在")
status = item.get("status", "")
# 终态/履约中任务:仅参与方与运营可见(发布方 / 接单方 / 运营)
if status not in ("published", "claimed", "doing"):
role = "public"
if user:
caps = user.get("capabilities") or []
if "operator" in caps or "platform_admin" in caps:
role = "operator"
elif str(item.get("publisher_id", "")) == user.get("id"):
role = "publisher"
else:
claims = await db.task_claims.list_by_task(task_id)
if any(c.get("claimer_user_id") == user.get("id")
and c.get("status") in ("doing", "completed", "delivered", "withdrawn")
for c in claims):
role = "claimer"
if role == "public":
raise HTTPException(status_code=404, detail="任务不存在")
if user:
svc = TaskService(db)
acc = await svc.can_accept(item, user)
item["can_accept"] = acc["ok"]
item["accept_reasons"] = acc["reasons"]
claims = await db.task_claims.list_by_task(task_id)
mine = [c for c in claims if c.get("claimer_user_id") == user["id"]]
item["myClaim"] = mine[0] if mine else None
item["myBid"] = next(
(b for b in await db.bids.list_for_task(task_id) if b.get("opc_id") == user["id"]), None)
if item.get("mode") == "register":
item["registerCount"] = sum(
1 for c in claims if c.get("claim_source") == "register" and c.get("status") == "registered")
# ── 完整详情聚合(阶段流转/沟通/交付/验收/结算)──
item["myRole"] = "operator" if (user.get("capabilities") or []) and any(
r in ("operator", "platform_admin") for r in user["capabilities"]) else \
("publisher" if str(item.get("publisher_id", "")) == user.get("id") else
("claimer" if mine else ("bidder" if item.get("myBid") else "viewer")))
item["deliveries"] = await db.task_deliveries.list_by_task(task_id)
item["milestones"] = await db.task_milestones.list_by_task(task_id)
item["acceptances"] = await db.task_acceptances.list_by_task(task_id)
item["invoices"] = await db.invoices.list_by_task(task_id)
item["timeline"] = _build_timeline(item, claims, item["deliveries"], item["acceptances"], item["invoices"])
item["participants"] = {
"publisher": {"id": item.get("publisher_id", ""), "name": item.get("publisher_name", "")},
"claimer": next(({
"id": c.get("claimer_user_id", ""), "name": c.get("claimer_name", "")
} for c in claims if c.get("status") in ("assigned", "claimed", "signed", "doing", "delivered", "completed")), None),
}
return item
def _build_timeline(task: dict, claims: list[dict], deliveries: list[dict],
acceptances: list[dict], invoices: list[dict]) -> list[dict]:
"""按时间顺序拼装任务阶段流转时间线(多端复用)"""
events: list[tuple[str, str, str, str]] = [] # (ts, type, actor, text)
ts = lambda v: str(v or "")
if task.get("created_at"):
events.append((ts(task["created_at"]), "publish", task.get("publisher_name", "发包方"),
f"发布任务「{task.get('title', '')}」,模式 {task.get('mode', '')}"))
if task.get("assigned_at"):
events.append((ts(task["assigned_at"]), "claim", task.get("claimed_by", "") or "发布方",
"指派/邀约已发出,等待接单方确认"))
if task.get("claimed_at"):
claimer = next((c.get("claimer_name", "") for c in claims if c.get("claimer_user_id") == task.get("claimed_by")), "")
events.append((ts(task["claimed_at"]), "claim", claimer or "接单方",
"接单方确认,任务锁定,待签约"))
if task.get("signed_at"):
events.append((ts(task["signed_at"]), "sign", task.get("publisher_name", "发包方"),
"签约并托管付款,任务进入服务期"))
if task.get("doing_at"):
events.append((ts(task["doing_at"]), "doing", "接单方", "开始做单"))
for d in sorted(deliveries, key=lambda x: x.get("created_at", "")):
events.append((ts(d.get("created_at")), "deliver", "接单方",
f"交付 v{d.get('version', 1)}{d.get('note', '') or '提交交付物'}"))
for a in sorted(acceptances, key=lambda x: x.get("created_at", "")):
if a.get("result") == "pass":
events.append((ts(a.get("created_at")), "accept", task.get("publisher_name", "发包方"),
f"验收通过{('(按时交付)' if a.get('on_time') else '(延期交付)')}{a.get('comment', '') or '无备注'}"))
else:
events.append((ts(a.get("created_at")), "reject", task.get("publisher_name", "发包方"),
f"退回返工:{a.get('comment', '') or '不符合验收标准'}"))
if task.get("settled_at"):
events.append((ts(task["settled_at"]), "settle", "平台", f"托管结算完成({task.get('settle_status', '')}"))
for inv in invoices:
events.append((ts(inv.get("created_at")), "invoice", inv.get("issuer_name", "接单方"),
f"申请开票 ¥{inv.get('amount', 0)}{inv.get('status', '')}"))
if task.get("status") == "cancelled":
events.append((ts(task.get("updated_at") or task.get("created_at", "")), "cancel",
task.get("publisher_name", "发包方"), "任务已取消"))
events.sort(key=lambda e: e[0])
return [{"time": e[0], "type": e[1], "actor": e[2], "text": e[3]} for e in events]
@router.post("/tasks", summary="发布任务(登录用户;operator 直接发布,其余进审核)")
async def hall_publish_task(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status_code=400, detail="请填写任务标题")
status = _publish_status(user)
caps = user.get("capabilities") or []
task = await db.tasks.create({
"title": title,
"category": str(body.get("category", "")).strip(),
"sub_category": str(body.get("subCategory", "") or body.get("sub_category", "")).strip(),
"description": str(body.get("description", "")),
"tags": str(body.get("tags", "")),
"mode": str(body.get("mode", "grab")).strip() or "grab",
"budget_min": int(body.get("budgetMin") or body.get("budget_min") or 0),
"budget_max": int(body.get("budgetMax") or body.get("budget_max") or 0),
"deadline": str(body.get("deadline", "")),
"delivery_days": int(body.get("deliveryDays") or body.get("delivery_days") or 0),
"headcount": int(body.get("headcount") or 0),
"register_quota": int(body.get("registerQuota") or body.get("register_quota") or 0),
"bid_quota": int(body.get("bidQuota") or body.get("bid_quota") or 0),
"win_quota": int(body.get("winQuota") or body.get("win_quota") or 0),
"eligibility": json.dumps(body.get("eligibility") or {}, ensure_ascii=False),
# ── 任务系统 v2 发布字段(0056)──
"resource_support_json": json.dumps(body.get("resourceSupport") or {}, ensure_ascii=False),
"raw_materials_json": json.dumps(body.get("rawMaterials") or [], ensure_ascii=False),
"deliver_way": str(body.get("deliverWay") or body.get("deliver_way") or "online"),
"deliver_items": json.dumps(body.get("deliverItems") or [], ensure_ascii=False),
"accept_standard": str(body.get("acceptStandard") or body.get("accept_standard") or ""),
"price_type": str(body.get("priceType") or body.get("price_type") or "fixed"),
"pay_type": str(body.get("payType") or body.get("pay_type") or "escrow"),
"escrow_ratio": int(body.get("escrowRatio") or body.get("escrow_ratio") or 100),
"invoice_req": json.dumps(body.get("invoiceReq") or {}, ensure_ascii=False),
"milestone_json": json.dumps(body.get("milestones") or [], ensure_ascii=False),
"min_credit_score": int(body.get("minCreditScore") or body.get("min_credit_score") or 0),
"required_badges": json.dumps(body.get("requiredBadges") or body.get("required_badges") or [], ensure_ascii=False),
"required_certifications": json.dumps(body.get("requiredCertifications") or body.get("required_certifications") or [], ensure_ascii=False),
"status": status,
"published_at": utcnow_iso() if status == "published" else "",
"publisher_id": user.get("id"),
"publisher_name": user.get("nickname") or user.get("username", ""),
"publisher_role": "enterprise" if "enterprise" in caps else ("carrier" if "carrier" in caps else ("operator" if "operator" in caps else "opc")),
"c_visible": True,
"ip": request.client.host if request.client else "",
})
await write_audit(db, action="hall.task.publish", resource="task", resource_id=task["id"],
detail=title, user=user, request=request)
# 实时通知:发布结果 + 待审核时提醒运营
from ...services.notification_service import notify as _n, notify_roles
if status == "published":
await _n(db, user["id"], "task", "任务发布成功",
f"{title}」已发布并进入大厅",
event_code="task.published", level="success", link="/opc/hall",
ref_type="task", ref_id=task["id"])
else:
await _n(db, user["id"], "task", "任务已提交审核",
f"{title}」已提交,审核通过后自动上架",
event_code="task.submitted", link="/opc/my-tasks",
ref_type="task", ref_id=task["id"])
await notify_roles(db, ["operator"], "ops", "新任务待审核",
f"新任务「{title}」待审核",
event_code="ops.task_review", link="/hall-admin",
ref_type="task", ref_id=task["id"])
# 任务发布即建任务群(fire-and-forgetIM 服务不可用不阻断)
try:
import asyncio
from ...im import client as im_client
asyncio.create_task(im_client.sync_task_group(task["id"], title))
except Exception: # noqa: BLE001
pass
return {"ok": True, "id": task["id"], "status": status, "item": task}
@router.post("/tasks/{task_id}/register", summary="报名任务(register 模式;先到先得,达上限关闭;可携带报名表单)")
async def hall_register_task(
task_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
actor: dict = Depends(require_cap("opc_member")),
):
from ...services.task_service import TaskService
# 报名表单:自我介绍/技能/期望报价/联系方式/备注/附件(可选)
form = {
"intro": str(body.get("intro") or "").strip(),
"skills": str(body.get("skills") or "").strip(),
"quote": int(body.get("quote") or 0),
"contact": str(body.get("contact") or "").strip(),
"remark": str(body.get("remark") or "").strip(),
"attachments": str(body.get("attachments") or "").strip(),
}
form = {k: v for k, v in form.items() if v not in ("", 0, [])}
claim = await TaskService(db).register(task_id, actor, form)
# 实时通知:报名者确认 + 发布者收报名提醒
from ...services.notification_service import notify as _n
task = await db.tasks.get(task_id)
if task:
await _n(db, actor["id"], "task", "报名成功", f"你已报名任务「{task.get('title')}",
event_code="task.applied", link="/opc/my-tasks", ref_type="task", ref_id=task_id)
await _n(db, task.get("publisher_id") or "", "task", "任务收到新报名",
f"你的任务「{task.get('title')}」收到新报名,请及时筛选",
event_code="task.applied", level="info", link="/opc/my-tasks", ref_type="task", ref_id=task_id)
await write_audit(db, action="hall.task.register", resource="task", resource_id=task_id,
detail=actor.get("username"), user=actor, request=request)
return {"ok": True, "claim": claim}
@router.get("/tasks/{task_id}/claims", summary="任务报名/接单列表(发包方或运营端可见)")
async def hall_task_claims(
task_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
caps = user.get("capabilities") or []
is_publisher = task.get("publisher_id") == user.get("id")
if not (is_publisher or "operator" in caps or "carrier" in caps or "enterprise" in caps):
raise HTTPException(status_code=403, detail="仅发包方或运营端可查看")
claims = await db.task_claims.list_by_task(task_id)
bids = await db.bids.list_for_task(task_id)
return {"claims": claims, "bids": bids}
# ===========================================================================
# 人才大厅
# ===========================================================================
@router.get("/talents", summary="人才大厅列表(OPC + 求职者)")
async def hall_talents(
type: str = "",
region: str = "",
field: str = "",
q: str = "",
db: Database = Depends(get_db),
):
from ...infrastructure.cache import cache as redis_cache
import hashlib
filter_hash = hashlib.md5(f"{type}:{region}:{field}:{q}".encode()).hexdigest()[:10]
cache_key = f"hall:talents:{filter_hash}"
cached = await redis_cache.get_json(cache_key)
if cached is not None:
return cached
items = await db.hall_talents.list(talent_type=type, region=region, q=q, field=field)
result = {"items": items}
import asyncio
asyncio.create_task(redis_cache.set_json(cache_key, result, ttl=20))
return result
@router.get("/talents/me", summary="我的人才档案(含未上架,供编辑页回填)")
async def hall_my_talent(db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
"""当前用户自己的档案:不受「上架」限制,未建档返回空对象(前端留空表单)。"""
return await db.hall_talents.get(user["id"]) or {}
@router.get("/talents/{user_id}", summary="人才详情")
async def hall_talent_detail(user_id: str, db: Database = Depends(get_db)):
profile = await db.hall_talents.get(user_id)
if profile is None or not profile.get("published"):
raise HTTPException(status_code=404, detail="人才档案不存在")
full = await db.users.get_by_id(user_id) or {}
out = dict(profile)
# 隐私:contact_ok=False 不出联系方式;OPC 附信用分与认证状态
out["creditScore"] = (full.get("opc_credit_score") if profile.get("contactOk") else None)
out["opcCertified"] = bool(full.get("opc_certified") or full.get("opc_cert"))
out["services"] = await db.hall_services.list(status="published", opc_id=user_id)
return out
@router.put("/talents/me", summary="维护我的人才档案(OPC 与求职者共用)")
async def hall_upsert_talent(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
patch = {
"talentType": str(body.get("talentType", "opc")).strip() or "opc",
"displayName": str(body.get("displayName", "")).strip() or user.get("nickname") or user.get("username", ""),
"avatar": str(body.get("avatar", "") or user.get("avatar", "")),
"headline": str(body.get("headline", "")).strip(),
"bio": str(body.get("bio", "")),
"fields": [str(x) for x in (body.get("fields") or []) if str(x).strip()],
"skills": [str(x) for x in (body.get("skills") or []) if str(x).strip()],
"workYears": int(body.get("workYears") or 0),
"education": str(body.get("education", "")),
"region": str(body.get("region", "")),
"contactOk": bool(body.get("contactOk", True)),
"published": True, # 保存档案即上架(进入人才大厅;下架走 /talents/me/publish 单独操作)
}
# 头像入库归一化为对象路径(/oss/<key>),出口再 resolve 为直链
if patch.get("avatar"):
from ...infrastructure.oss import to_object_path
patch["avatar"] = to_object_path(patch["avatar"])
profile = await db.hall_talents.upsert(user["id"], patch)
await write_audit(db, action="hall.talent.upsert", resource="talent_profile",
resource_id=user["id"], user=user, request=request)
return {"ok": True, "profile": profile}
@router.put("/talents/me/publish", summary="上架/下架我的人才档案")
async def hall_talent_publish(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
"""published=True 上架(进入人才大厅);False 下架(大厅不再展示,本人仍可见)。"""
profile = await db.hall_talents.set_published(user["id"], bool(body.get("published", True)))
await write_audit(db, action="hall.talent.publish", resource="talent_profile",
resource_id=user["id"], user=user, request=request,
detail=f"published={bool(body.get('published', True))}")
return {"ok": True, "profile": profile}
# ===========================================================================
# 岗位大厅
# ===========================================================================
@router.get("/jobs", summary="岗位大厅列表(publishedenterprise 可见自己全部)")
async def hall_jobs(
category: str = "",
work_type: str = "",
q: str = "",
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
from ...infrastructure.cache import cache as redis_cache
import hashlib
uid = user.get("id") if user and "enterprise" in (user.get("capabilities") or []) else "anon"
filter_hash = hashlib.md5(f"{category}:{work_type}:{q}".encode()).hexdigest()[:10]
cache_key = f"hall:jobs:{uid}:{filter_hash}"
cached = await redis_cache.get_json(cache_key)
if cached is not None:
return cached
if user and "enterprise" in (user.get("capabilities") or []):
mine = await db.hall_jobs.list(status="", publisher_id=user["id"])
pub = await db.hall_jobs.list(status="published", category=category, work_type=work_type, q=q)
seen = {i["id"] for i in pub}
result = {"items": pub + [i for i in mine if i["id"] not in seen]}
else:
result = {"items": await db.hall_jobs.list(status="published", category=category,
work_type=work_type, q=q)}
import asyncio
asyncio.create_task(redis_cache.set_json(cache_key, result, ttl=20))
return result
# 字面路径必须在参数化路径之前注册
@router.get("/jobs/mine", summary="我发布的岗位(含投递数)")
async def hall_my_jobs(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
items = await db.hall_jobs.list(status="", publisher_id=user["id"])
out = []
for j in items:
apps = await db.hall_job_apps.list_for_job(j["id"])
out.append({**j, "applicationCount": len(apps),
"newApplicationCount": sum(1 for a in apps if a.get("status") == "submitted")})
return {"items": out}
@router.get("/jobs/{job_id}", summary="岗位详情")
async def hall_job_detail(job_id: str, db: Database = Depends(get_db)):
item = await db.hall_jobs.get(job_id)
if item is None or item.get("status") not in ("published", "closed"):
raise HTTPException(status_code=404, detail="岗位不存在")
return item
@router.post("/jobs", summary="发布岗位(enterprise/carrier/operator")
async def hall_publish_job(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("enterprise", "carrier", "operator")),
):
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status_code=400, detail="请填写岗位名称")
status = _publish_status(user)
# 优先从 organization_members 获取企业组织,回退 user.org_id
ent_org = await get_user_primary_org(db, user, org_type="enterprise")
company_id = ent_org["org_id"] if ent_org else user.get("org_id")
job = await db.hall_jobs.create(
company_id=company_id or None,
company_name=str(body.get("companyName", "")).strip() or user.get("company", ""),
title=title,
category=str(body.get("category", "")).strip(),
description=str(body.get("description", "")),
requirement=str(body.get("requirement", "")),
tags=str(body.get("tags", "")),
salary_min=int(body.get("salaryMin") or 0),
salary_max=int(body.get("salaryMax") or 0),
location=str(body.get("location", "")),
work_type=str(body.get("workType", "fulltime")) or "fulltime",
headcount=int(body.get("headcount") or 1),
contact=str(body.get("contact", "")),
publisher_id=user.get("id"),
status=status,
published_at=utcnow_iso() if status == "published" else "",
ip=request.client.host if request.client else "",
)
await write_audit(db, action="hall.job.publish", resource="job", resource_id=job["id"],
detail=title, user=user, request=request)
return {"ok": True, "id": job["id"], "status": status}
@router.post("/jobs/{job_id}/apply", summary="投递简历(opc_member 及以上)")
async def hall_apply_job(
job_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
job = await db.hall_jobs.get(job_id)
if job is None or job.get("status") != "published":
raise HTTPException(status_code=404, detail="岗位不存在或未发布")
existing = [a for a in await db.hall_job_apps.list_for_job(job_id) if a.get("userId") == user["id"]]
if existing:
raise HTTPException(status_code=400, detail="你已投递该岗位")
app_ = await db.hall_job_apps.create(
job_id, user["id"], user.get("nickname") or user.get("username", ""),
contact=str(body.get("contact", "")), note=str(body.get("note", "")))
await write_audit(db, action="hall.job.apply", resource="job", resource_id=job_id,
detail=user.get("username"), user=user, request=request)
return {"ok": True, "id": app_["id"]}
@router.get("/jobs/{job_id}/applications", summary="岗位投递列表(发布者/企业/运营端)")
async def hall_job_applications(
job_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("enterprise", "operator", "carrier")),
):
job = await db.hall_jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="岗位不存在")
if job.get("publisherId") != user.get("id") and "operator" not in (user.get("capabilities") or []):
raise HTTPException(status_code=403, detail="仅岗位发布方或运营端可查看")
return {"items": await db.hall_job_apps.list_for_job(job_id)}
# ===========================================================================
# 服务大厅(OPC 个人服务)
# ===========================================================================
@router.get("/services", summary="服务大厅列表(published;可按 OPC 过滤)")
async def hall_services(
category: str = "",
q: str = "",
opc_id: str = "",
db: Database = Depends(get_db),
):
from ...infrastructure.cache import cache as redis_cache
import hashlib
filter_hash = hashlib.md5(f"{category}:{q}:{opc_id}".encode()).hexdigest()[:10]
cache_key = f"hall:services:{filter_hash}"
cached = await redis_cache.get_json(cache_key)
if cached is not None:
return cached
result = {"items": await db.hall_services.list(status="published", category=category,
q=q, opc_id=opc_id)}
import asyncio
asyncio.create_task(redis_cache.set_json(cache_key, result, ttl=20))
return result
@router.get("/services/mine", summary="我的服务(发布者视角,含审核状态)")
async def hall_my_services(
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
return {"items": await db.hall_services.list(status="", opc_id=user["id"])}
@router.get("/services/{service_id}", summary="服务详情(含作品集与 OPC 档案)")
async def hall_service_detail(service_id: str, db: Database = Depends(get_db)):
item = await db.hall_services.get(service_id)
if item is None or item.get("status") != "published":
raise HTTPException(status_code=404, detail="服务不存在")
if item.get("opcId"):
item["talent"] = await db.hall_talents.get(item["opcId"])
return item
@router.post("/services", summary="发布服务(opc_member;分类限服务类;operator 直接上架)")
async def hall_publish_service(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
title = str(body.get("title", "")).strip()
if not title:
raise HTTPException(status_code=400, detail="请填写服务名称")
status = _publish_status(user)
svc = await db.hall_services.create(
opc_id=user["id"],
opc_name=user.get("nickname") or user.get("username", ""),
title=title,
category=str(body.get("category", "")).strip(),
description=str(body.get("description", "")),
cover=str(body.get("cover", "")),
gallery=[str(x) for x in (body.get("gallery") or []) if str(x).strip()],
price=int(body.get("price") or 0),
delivery_days=int(body.get("deliveryDays") or 0),
tags=str(body.get("tags", "")),
status=status,
ip=request.client.host if request.client else "",
)
await write_audit(db, action="hall.service.publish", resource="opc_service",
resource_id=svc["id"], detail=title, user=user, request=request)
# 实时通知:发布结果 + 待审核时提醒运营
from ...services.notification_service import notify as _n, notify_roles
if status == "published":
await _n(db, user["id"], "ops", "服务发布成功",
f"{title}」已上架到服务市场",
event_code="ops.service_published", level="success", link="/opc/services",
ref_type="opc_service", ref_id=svc["id"])
else:
await _n(db, user["id"], "ops", "服务已提交审核",
f"{title}」已提交,审核通过后自动上架",
event_code="ops.service_submitted", link="/opc/services",
ref_type="opc_service", ref_id=svc["id"])
await notify_roles(db, ["operator"], "ops", "新服务待审核",
f"新服务「{title}」待审核",
event_code="ops.service_review", link="/hall-admin",
ref_type="opc_service", ref_id=svc["id"])
# 信用事件:发布服务 +2(按服务去重)
if status == "published":
from ...services.credit_engine import on_credit_event
await on_credit_event(
db, user_id=user["id"], event_code="service.publish",
ref_type="opc_service", ref_id=svc["id"], reason=f"发布服务「{title}",
)
return {"ok": True, "id": svc["id"], "status": status}
@router.put("/services/{service_id}", summary="编辑/上下架我的服务(发布者或运营端)")
async def hall_update_service(
service_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
svc = await db.hall_services.get(service_id)
if svc is None:
raise HTTPException(status_code=404, detail="服务不存在")
caps = user.get("capabilities") or []
if svc.get("opcId") != user.get("id") and "operator" not in caps:
raise HTTPException(status_code=403, detail="仅服务发布者或运营端可编辑")
allowed = ("title", "category", "description", "cover", "price", "delivery_days", "tags", "status")
patch = {}
for k in allowed:
camel = "".join(p.capitalize() for p in k.split("_"))
if k in body:
patch[k] = body[k]
elif camel in body:
patch[k] = body[camel]
if "status" in patch and patch["status"] == "published" and "operator" not in caps:
patch["status"] = "pending" # 编辑后回审核
updated = await db.hall_services.update(service_id, patch)
await write_audit(db, action="hall.service.update", resource="opc_service",
resource_id=service_id, user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/services/{service_id}/inquiry", summary="服务咨询/下单意向")
async def hall_service_inquiry(
service_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
svc = await db.hall_services.get(service_id)
if svc is None or svc.get("status") != "published":
raise HTTPException(status_code=404, detail="服务不存在")
inq = await db.hall_inquiries.create(
service_id, user["id"], user.get("nickname") or user.get("username", ""),
contact=str(body.get("contact", "")), note=str(body.get("note", "")))
await write_audit(db, action="hall.service.inquiry", resource="opc_service",
resource_id=service_id, user=user, request=request)
# 实时通知:服务方收到咨询
if svc.get("opcId") and svc.get("opcId") != user["id"]:
from ...services.notification_service import notify as _n
await _n(db, svc.get("opcId"), "ops", "收到服务咨询",
f"你的服务「{svc.get('title', '')}」收到新的咨询/下单意向",
event_code="ops.service_inquiry", link="/opc/services",
ref_type="opc_service", ref_id=service_id)
return {"ok": True, "id": inq["id"]}
# ===========================================================================
# 社区(线上帖子 + 实体载体)
# ===========================================================================
community_router = APIRouter(prefix="/community", tags=["community"])
@community_router.get("/overview", summary="社区 Hub(最新帖子 + 载体推荐)")
async def community_overview(db: Database = Depends(get_db)):
posts = (await db.community_posts.list(status="published"))[:5]
tenants = (await db.session.execute(
select(ParkTenant).where(ParkTenant.status == "active").limit(5))).scalars().all()
carriers = [{"id": t.id, "name": t.name, "city": t.city, "cover": t.cover, "introHtml": t.intro_html[:120]}
for t in tenants]
return {"posts": posts, "carriers": carriers,
"topics": ["交流", "求助", "资源", "经验", "公告"]}
@community_router.get("/posts", summary="帖子流(published;按话题/关键字筛选)")
async def community_posts(topic: str = "", q: str = "", db: Database = Depends(get_db)):
return {"items": await db.community_posts.list(status="published", topic=topic, q=q)}
@community_router.get("/posts/mine", summary="我的帖子(含审核状态)")
async def community_my_posts(db: Database = Depends(get_db),
user: dict = Depends(get_current_user)):
return {"items": await db.community_posts.list(status="", author_id=user["id"])}
@community_router.get("/posts/{post_id}", summary="帖子详情(正文 + 评论树)")
async def community_post_detail(post_id: str, db: Database = Depends(get_db)):
post = await db.community_posts.get(post_id)
if post is None or post.get("status") != "published":
raise HTTPException(status_code=404, detail="帖子不存在")
comments = await db.community_comments.list_for_post(post_id)
return {"post": post, "comments": comments}
@community_router.post("/posts", summary="发帖(登录用户;admin 可后台治理)")
async def community_create_post(
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
title = str(body.get("title", "")).strip()
content = str(body.get("content", "")).strip()
if not title or not content:
raise HTTPException(status_code=400, detail="请填写标题与正文")
post = await db.community_posts.create(
author_id=user["id"],
author_name=user.get("nickname") or user.get("username", ""),
avatar=user.get("avatar", ""),
topic=str(body.get("topic", "交流")).strip() or "交流",
title=title, content=content,
images=[str(x) for x in (body.get("images") or []) if str(x).strip()],
status="published",
ip=request.client.host if request.client else "",
)
await write_audit(db, action="community.post.create", resource="community_post",
resource_id=post["id"], user=user, request=request)
return {"ok": True, "id": post["id"], "item": post}
@community_router.post("/posts/{post_id}/like", summary="点赞/取消点赞帖子")
async def community_like_post(
post_id: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
post = await db.community_posts.get(post_id)
if post is None:
raise HTTPException(status_code=404, detail="帖子不存在")
if await db.community_likes.has("post", post_id, user["id"]):
await db.community_likes.remove("post", post_id, user["id"])
await db.community_posts.inc_likes(post_id, -1)
return {"ok": True, "liked": False}
await db.community_likes.add("post", post_id, user["id"])
await db.community_posts.inc_likes(post_id, 1)
# 实时通知:点赞(自己赞自己不打扰)
if post.get("author_id") and post.get("author_id") != user["id"]:
from ...services.notification_service import notify as _n
await _n(db, post.get("author_id"), "social", "收到点赞",
f"有人赞了你的帖子「{post.get('title', '')}",
event_code="like.received", link="/opc/hall",
ref_type="community_post", ref_id=post_id)
return {"ok": True, "liked": True}
@community_router.post("/posts/{post_id}/comments", summary="评论帖子(支持楼中楼)")
async def community_comment_post(
post_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
post = await db.community_posts.get(post_id)
if post is None or post.get("status") != "published":
raise HTTPException(status_code=404, detail="帖子不存在")
content = str(body.get("content", "")).strip()
if not content:
raise HTTPException(status_code=400, detail="请填写评论内容")
cmt = await db.community_comments.create(
post_id, user["id"], user.get("nickname") or user.get("username", ""),
content, parent_id=str(body.get("parentId", "")),
ip=request.client.host if request.client else "")
await db.community_posts.inc_comments(post_id, 1)
# 实时通知:评论作者 / 被回复者
from ...services.notification_service import notify as _n
if post.get("author_id") and post.get("author_id") != user["id"]:
await _n(db, post.get("author_id"), "social", "帖子有新评论",
f"你的帖子「{post.get('title', '')}」收到新评论",
event_code="social.commented", link="/opc/hall",
ref_type="community_post", ref_id=post_id)
parent_id = str(body.get("parentId", "")).strip()
if parent_id:
try:
parent = await db.community_comments.get(parent_id)
if parent and parent.get("user_id") and parent.get("user_id") != user["id"]:
await _n(db, parent.get("user_id"), "social", "收到评论回复",
"有人回复了你的评论",
event_code="social.replied", link="/opc/hall",
ref_type="community_post", ref_id=post_id)
except Exception: # noqa: BLE001
pass
await write_audit(db, action="community.comment", resource="community_post",
resource_id=post_id, user=user, request=request)
return {"ok": True, "comment": cmt}
@community_router.get("/carriers", summary="实体社区载体列表(云南省内创业载体,公开)")
async def community_carriers(region: str = "", q: str = "", db: Database = Depends(get_db)):
from sqlalchemy import select
from ...infrastructure.models import ParkCompany, ParkTenant
rows = (await db.session.execute(
select(ParkTenant).where(ParkTenant.status == "active"))).scalars().all()
items = []
for t in rows:
if region and (t.city or "") != region:
continue
if q and q.lower() not in (t.name or "").lower():
continue
companies = (await db.session.execute(
select(ParkCompany).where(ParkCompany.tenant_id == t.id))).scalars().all()
items.append({
"id": t.id, "name": t.name, "city": t.city, "cover": t.cover,
"address": t.address, "contact": t.contact, "tags": t.tags,
"areaM2": t.area_m2, "stats": t.stats_json,
"companyCount": len(companies),
})
return {"items": items}
@community_router.get("/carriers/{carrier_id}", summary="载体详情(实体社区)")
async def community_carrier_detail(carrier_id: str, db: Database = Depends(get_db)):
from sqlalchemy import select
from ...infrastructure.models import ParkCompany, ParkTenant
t = await db.session.get(ParkTenant, carrier_id)
if t is None or t.status != "active":
raise HTTPException(status_code=404, detail="载体不存在")
companies = (await db.session.execute(
select(ParkCompany).where(ParkCompany.tenant_id == t.id)
.order_by(ParkCompany.created_at.desc()))).scalars().all()
return {
"id": t.id, "name": t.name, "city": t.city, "cover": t.cover,
"lng": t.lng, "lat": t.lat, "address": t.address, "contact": t.contact,
"tags": t.tags, "areaM2": t.area_m2,
"introHtml": t.intro_html, "policyHtml": t.policy_html, "admissionHtml": t.admission_html,
"services": json.loads(t.services_json or "[]"),
"stats": t.stats_json,
"companies": [{"id": c.id, "name": c.name, "industry": c.industry, "bio": c.bio}
for c in companies[:60]],
"companyCount": len(companies),
"followerCount": await db.community_follows.count("carrier", t.id),
}
@community_router.post("/carriers/{carrier_id}/follow", summary="关注/取消关注载体")
async def community_follow_carrier(
carrier_id: str,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
t = await db.session.get(ParkTenant, carrier_id)
if t is None:
raise HTTPException(status_code=404, detail="载体不存在")
res = await db.community_follows.toggle(user["id"], "carrier", carrier_id)
return {"ok": True, **res}
@community_router.post("/carriers/{carrier_id}/join", summary="申请入驻载体(复用入驻审核流)")
async def community_join_carrier(
carrier_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(get_current_user),
):
from ...infrastructure.models import ParkTenant
t = await db.session.get(ParkTenant, carrier_id)
if t is None or t.status != "active":
raise HTTPException(status_code=404, detail="载体不存在")
adm = await db.park_admissions.create(
tenant_id=t.id, tenant_name=t.name, user_id=user["id"],
username=user.get("username", ""),
applicant_name=str(body.get("name", "")).strip() or user.get("nickname", ""),
contact_phone=str(body.get("phone", "")).strip(),
form_json=json.dumps(body.get("form") or {}, ensure_ascii=False),
source="community")
await write_audit(db, action="community.carrier.join", resource="park_admission",
resource_id=adm["id"], user=user, request=request)
return {"ok": True, "id": adm["id"], "status": adm["status"]}
# ===========================================================================
# 服务订单(预约/下单 → 接单 → 交付 → 完成,0036)
# ===========================================================================
async def _send_user_sms(db, user_id: str, scene: str, params: dict[str, str]) -> None:
"""向指定用户手机号发送场景短信;无手机号/发送失败均不影响主业务。"""
if not user_id:
return
try:
u = await db.users.get_by_id(user_id)
except Exception:
u = None
phone = str((u or {}).get("phone") or "").strip()
if not phone:
return
try:
from ...services import sms
cfg = await sms.load_sms_config(db)
await sms.send_template(cfg, scene, phone, params)
except Exception as exc: # 短信失败不阻断业务
print(f"[sms] {scene} -> {phone} 发送失败(已忽略): {exc}")
@router.post("/services/{service_id}/order", summary="预约/下单服务(buyer")
async def hall_order_service(
service_id: str,
body: dict,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
svc = await db.hall_services.get(service_id)
if svc is None or svc.get("status") != "published":
raise HTTPException(status_code=404, detail="服务不存在")
if svc.get("opcId") == user.get("id"):
raise HTTPException(status_code=400, detail="不能下单自己的服务")
contact = str(body.get("contact", "")).strip()
if not contact:
raise HTTPException(status_code=400, detail="请填写联系方式")
order = await db.service_orders.create(
service_id=service_id, opc_id=svc.get("opcId"), buyer_id=user["id"],
buyer_name=user.get("nickname") or user.get("username", ""),
contact=contact, schedule_at=str(body.get("scheduleAt", "")).strip(),
note=str(body.get("note", "")), price=int(body.get("price") or 0))
await write_audit(db, action="hall.service.order", resource="service_order",
resource_id=order["id"], user=user, request=request)
# 服务预约短信 → 服务方(appointment 场景,模板变量见 system_configs
await _send_user_sms(db, svc.get("opcId"), "appointment", {
"service_name": svc.get("title") or "",
"schedule_at": str(body.get("scheduleAt", "") or ""),
"contact": contact,
})
return {"ok": True, "id": order["id"], "status": order["status"], "item": order}
@router.get("/orders/bought", summary="我下单的服务订单(buyer")
async def hall_orders_bought(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
return {"items": await db.service_orders.list(buyer_id=user["id"])}
@router.get("/orders/selling", summary="我收到的服务订单(服务方)")
async def hall_orders_selling(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
return {"items": await db.service_orders.list(opc_id=user["id"])}
@router.post("/orders/{order_id}/{action}", summary="订单流转(accept/reject/start/deliver/complete/cancel")
async def hall_order_transition(
order_id: str,
action: str,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
order = await db.service_orders.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="订单不存在")
is_seller = order.get("opcId") == user.get("id")
is_buyer = order.get("buyerId") == user.get("id")
is_operator = "operator" in (user.get("capabilities") or [])
if not (is_seller or is_buyer or is_operator):
raise HTTPException(status_code=403, detail="无权操作该订单")
# 权限约束:accept/reject/start/deliver 仅服务方(或运营);cancel 买卖双方皆可;complete 仅买方
seller_only = {"accept", "reject", "start", "deliver"}
buyer_only = {"complete"}
if action in seller_only and not (is_seller or is_operator):
raise HTTPException(status_code=403, detail="仅服务方可操作")
if action in buyer_only and not (is_buyer or is_operator):
raise HTTPException(status_code=403, detail="仅买方可确认完成")
if action == "cancel" and not (is_seller or is_buyer or is_operator):
raise HTTPException(status_code=403, detail="无权取消")
try:
updated = await db.service_orders.transition(order_id, action)
except KeyError:
raise HTTPException(status_code=404, detail="订单不存在")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
await write_audit(db, action=f"hall.order.{action}", resource="service_order",
resource_id=order_id, user=user, request=request)
# 订单状态变动短信 → 交易对手方(order_status 场景)
status_map = {
"accept": "已接单", "reject": "已拒绝", "start": "进行中",
"deliver": "已交付", "complete": "已完成", "cancel": "已取消",
}
target = order.get("buyerId") if is_seller else order.get("opcId")
if action in ("complete", "cancel"):
target = order.get("opcId") if is_buyer else order.get("buyerId")
await _send_user_sms(db, target, "order_status", {
"order_no": order.get("id") or "",
"status": status_map.get(action, action),
})
return {"ok": True, "item": updated}
# ===========================================================================
# 发包方任务操作(定标 / 指派 / 报名选定 / 取消,0036)
# ===========================================================================
def _ensure_task_owner(task: dict, user: dict) -> None:
caps = user.get("capabilities") or []
if task.get("publisher_id") != user.get("id") and "operator" not in caps:
raise HTTPException(status_code=403, detail="仅发包方或运营端可操作")
@router.post("/tasks/{task_id}/win", summary="评标定标(发包方/运营端;bid → win,任务 → claimed")
async def hall_task_win(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
updated = await TaskService(db).win_bid(task_id, str(body.get("bidId", "")), user)
await write_audit(db, action="hall.task.win", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/review-bid", summary="招标评审评分(发包方/运营端;综合得分写入标书)")
async def hall_task_review_bid(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
bid = await TaskService(db).review_bid(
task_id, str(body.get("bidId", "")), user,
scores=body.get("scores"), note=str(body.get("note", "")),
)
await write_audit(db, action="hall.task.review_bid", resource="bid", resource_id=bid["id"],
user=user, request=request)
return {"ok": True, "item": bid}
@router.post("/tasks/{task_id}/assign", summary="指派接单人(发包方/运营端;assign 模式)")
async def hall_task_assign(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
updated = await TaskService(db).assign(task_id, str(body.get("userId", "")), user)
await write_audit(db, action="hall.task.assign", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/select", summary="报名选定(发包方/运营端;register 模式)")
async def hall_task_select(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
updated = await TaskService(db).register_select(task_id, str(body.get("claimId", "")), user)
await write_audit(db, action="hall.task.select", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/cancel", summary="取消任务(发包方/运营端;published/claimed 可取消)")
async def hall_task_cancel(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
updated = await TaskService(db).cancel(task_id, user)
await write_audit(db, action="hall.task.cancel", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
# ── 任务统一合并到大厅:OPC 接单侧操作(原 /opc/tasks/*2026-09 增量合并) ──
@router.post("/tasks/{task_id}/grab", summary="抢单(grab 模式,先到先得;可携带接单备注)")
async def hall_task_grab(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
form = {}
remark = str(body.get("remark") or "").strip()
if remark:
form["remark"] = remark
updated = await TaskService(db).grab(task_id, user, form or None)
# 信用事件:接单成功 +1
if updated and updated.get("status") in ("doing", "claimed", "accepted"):
from ...services.credit_engine import on_credit_event
await on_credit_event(
db, user_id=user["id"], event_code="task.accept",
ref_type="task", ref_id=task_id, reason="成功接单",
)
# 实时通知:抢单成功(接单人) + 任务被抢(发布者)
from ...services.notification_service import notify as _n
await _n(db, user["id"], "task", "抢单成功", f"你已接下任务「{updated.get('title')}",
event_code="task.claimed", link="/opc/my-tasks", ref_type="task", ref_id=task_id)
await _n(db, updated.get("publisher_id") or "", "task", "任务已被接单",
f"你的任务「{updated.get('title')}」已有人接单",
event_code="task.grabbed", link="/opc/my-tasks", ref_type="task", ref_id=task_id)
await write_audit(db, action="hall.task.grab", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/sign", summary="签约并托管(发布方:claimed → signed,任务进入服务期)")
async def hall_task_sign(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).sign(task_id, user, str(body.get("payType") or ""))
await write_audit(db, action="hall.task.sign", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/accept-invite", summary="确认接受任务(接单方:assigned → claimed")
async def hall_task_accept_invite(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).accept_invite(task_id, user)
await write_audit(db, action="hall.task.accept_invite", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/grab-by-code", summary="扫码/按短码接单")
async def hall_task_grab_by_code(
req: TaskClaimRequest,
request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member")),
):
from ...services.task_service import TaskService
updated = await TaskService(db).claim(
(req.task_code or "").strip(), user, source="scan",
)
# 实时通知:扫码接单成功 + 发布者被接
if updated and updated.get("status") in ("doing", "claimed", "accepted"):
from ...services.notification_service import notify as _n
await _n(db, user["id"], "task", "接单成功", f"你已接下任务「{updated.get('title')}",
event_code="task.claimed", link="/opc/my-tasks", ref_type="task", ref_id=updated["id"])
await _n(db, updated.get("publisher_id") or "", "task", "任务已被接单",
f"你的任务「{updated.get('title')}」已有人接单",
event_code="task.grabbed", link="/opc/my-tasks", ref_type="task", ref_id=updated["id"])
await write_audit(db, action="hall.task.claim", resource="task",
resource_id=updated["id"], user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/bid", summary="投标(bid 模式,六段标书:报价/方案/材料/工期/团队/案例/承诺)")
async def hall_task_bid(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
bid = await TaskService(db).bid(
task_id, user,
int(body.get("quote") or 0),
str(body.get("plan") or ""),
material=str(body.get("material") or ""),
deliver_days=int(body.get("deliverDays") or body.get("deliver_days") or 0),
team_json=json.dumps(body.get("team") or [], ensure_ascii=False),
cases_json=json.dumps(body.get("cases") or [], ensure_ascii=False),
commit_json=json.dumps(body.get("commit") or {}, ensure_ascii=False),
)
await write_audit(db, action="hall.task.bid", resource="bid", resource_id=bid["id"],
user=user, request=request)
# 实时通知:发布者收到投标提醒
task = await db.tasks.get(task_id)
if task:
from ...services.notification_service import notify as _n
await _n(db, task.get("publisher_id") or "", "task", "收到新投标",
f"任务「{task.get('title')}」收到新方案,请及时评审",
event_code="task.bid_received", link="/opc/my-tasks", ref_type="task", ref_id=task_id)
return {"ok": True, "item": bid}
@router.post("/tasks/{task_id}/doing", summary="开始做单")
async def hall_task_doing(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).start_doing(task_id, user)
await write_audit(db, action="hall.task.doing", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/complete", summary="完成做单(v3 语义:立即交付)")
async def hall_task_complete(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).complete(task_id, user)
await write_audit(db, action="hall.task.complete", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/deliver", summary="交付成果(版本化;附件 + 说明 + 里程碑交付)")
async def hall_task_deliver(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).deliver(
task_id, user,
attachments=body.get("attachments") or [],
note=str(body.get("note") or ""),
milestone_id=str(body.get("milestoneId") or ""),
)
await write_audit(db, action="hall.task.deliver", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/accept", summary="验收通过(发包方;delivered → completed")
async def hall_task_accept(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).accept(
task_id, user,
checklist=body.get("checklist") or [],
score=body.get("score"),
comment=str(body.get("comment") or ""),
milestone_id=str(body.get("milestoneId") or ""),
)
await write_audit(db, action="hall.task.accept", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/reject", summary="验收不通过/返工(发包方;delivered → doing,上限 3 次)")
async def hall_task_reject(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).reject(
task_id, user, comment=str(body.get("comment") or ""),
)
await write_audit(db, action="hall.task.reject", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.post("/tasks/{task_id}/withdraw", summary="接单人退出(claimed → published 释放)")
async def hall_task_withdraw(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
updated = await TaskService(db).withdraw(task_id, user)
await write_audit(db, action="hall.task.withdraw", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "item": updated}
@router.get("/tasks/{task_id}/deliveries", summary="任务交付历史 + 验收记录 + 里程碑(任务相关方可见)")
async def hall_task_deliveries(task_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
caps = user.get("capabilities") or []
if (task.get("publisher_id") or "") != user.get("id") and task.get("claimed_by") != user.get("id") \
and not (set(caps) & {"operator", "carrier", "enterprise"}):
raise HTTPException(status_code=403, detail="仅任务相关方可查看")
return {
"deliveries": await db.task_deliveries.list_by_task(task_id),
"acceptances": await db.task_acceptances.list_by_task(task_id),
"milestones": await db.task_milestones.list_by_task(task_id),
}
@router.post("/tasks/{task_id}/escrow", summary="发包方自助托管(escrow 建档;全额/阶段)")
async def hall_task_escrow(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if (task.get("publisher_id") or "") != user.get("id"):
raise HTTPException(status_code=403, detail="仅发包方可托管资金")
if task["status"] not in ("claimed", "doing"):
raise HTTPException(status_code=400, detail="任务当前状态不可托管")
amount = int(body.get("amount") or 0)
if amount <= 0:
amount = task.get("budget_max") or task.get("budget_min") or 0
if amount <= 0:
raise HTTPException(status_code=400, detail="请指定托管金额")
escrow = await db.escrows.create(
task_id=task_id, task_title=task.get("title") or "", amount=amount,
)
await db.tasks.set_settle(task_id, "pending")
await write_audit(db, action="hall.task.escrow", resource="escrow", resource_id=escrow["id"],
user=user, request=request)
return {"ok": True, "escrow": escrow}
@router.post("/tasks/{task_id}/settle", summary="结算(验收通过后;微信分账或台账降级)")
async def hall_task_settle(task_id: str, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.settlement_service import SettlementService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if (task.get("publisher_id") or "") != user.get("id"):
raise HTTPException(status_code=403, detail="仅发包方可发起结算")
if task["status"] != "completed":
raise HTTPException(status_code=400, detail="任务未验收通过,无法结算")
if task.get("settle_status") == "released":
raise HTTPException(status_code=400, detail="任务已结算")
result = await SettlementService(db).release_escrow(task_id)
await db.tasks.set_settle(task_id, "released")
# 实时通知:接单人结算到账
from ...services.notification_service import notify as _n
await _n(db, task.get("claimed_by") or "", "task", "任务已结算",
f"{task.get('title')}」款项已结算,请查收",
event_code="task.settled", level="success", link="/opc/my-tasks", ref_type="task", ref_id=task_id)
await write_audit(db, action="hall.task.settle", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "result": result}
@router.post("/tasks/{task_id}/invoice", summary="申请开票(接单人;结算后)")
async def hall_task_invoice(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
if task.get("claimed_by") != user.get("id"):
raise HTTPException(status_code=403, detail="仅接单人可申请开票")
if task["status"] != "completed":
raise HTTPException(status_code=400, detail="任务完成后才可开票")
amount = int(body.get("amount") or 0) or (task.get("budget_max") or 0)
if amount <= 0:
raise HTTPException(status_code=400, detail="请指定开票金额")
inv = await db.invoices.create(
task_id=task_id,
escrow_id=str(body.get("escrowId") or ""),
issuer_id=user["id"],
issuer_name=user.get("nickname") or user.get("username", ""),
payer_name=str(body.get("payerName") or task.get("publisher_name") or ""),
amount=amount,
tax_rate=float(body.get("taxRate") or 0.01),
invoice_type=str(body.get("invoiceType") or "personal"),
)
await write_audit(db, action="hall.task.invoice", resource="invoice", resource_id=inv["id"],
user=user, request=request)
return {"ok": True, "invoice": inv}
@router.get("/invoices/mine", summary="我的开票记录")
async def hall_my_invoices(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
return {"items": await db.invoices.list_by_issuer(user["id"])}
@router.post("/tasks/{task_id}/recommend", summary="推荐候选人(发包方/运营端)")
async def hall_task_recommend(task_id: str, body: dict, request: Request,
db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
from ...services.task_service import TaskService
task = await db.tasks.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="任务不存在")
_ensure_task_owner(task, user)
records = await TaskService(db).recommend(task_id, body.get("candidates") or [], user)
await write_audit(db, action="hall.task.recommend", resource="task", resource_id=task_id,
user=user, request=request)
return {"ok": True, "items": records}
# ===========================================================================
# 站内会话(点对点私信,0041
# ===========================================================================
dm_router = APIRouter(prefix="/dm", tags=["dm"])
@dm_router.get("/conversations", summary="我的会话列表(按对方聚合,含未读数)")
async def dm_conversations(db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
convs = await db.dm.conversations(user["id"])
# 补对方展示信息
from ...infrastructure.oss import resolve_url
out = []
for c in convs:
peer = await db.users.get_by_id(c["peerId"]) or {}
talent = await db.hall_talents.get(c["peerId"])
out.append({**c, "peerName": peer.get("nickname") or peer.get("username", ""),
"peerAvatar": resolve_url(peer.get("avatar", "")),
"peerHeadline": (talent or {}).get("headline", "")})
return {"items": out}
@dm_router.get("/messages", summary="与某人的消息记录(进入会话即标记已读)")
async def dm_messages(peer: str, db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
items = await db.dm.thread(user["id"], peer)
await db.dm.mark_read(user["id"], peer)
peer = await db.users.get_by_id(peer) or {}
from ...infrastructure.oss import resolve_url
return {"items": items, "peerName": peer.get("nickname") or peer.get("username", ""),
"peerAvatar": resolve_url(peer.get("avatar", ""))}
@dm_router.post("/messages", summary="发送私信")
async def dm_send(body: dict, request: Request, db: Database = Depends(get_db),
user: dict = Depends(require_cap("opc_member"))):
peer = str(body.get("peerId", "")).strip()
content = str(body.get("content", "")).strip()
if not peer or not content:
raise HTTPException(status_code=400, detail="请填写接收人与内容")
if peer == user.get("id"):
raise HTTPException(status_code=400, detail="不能给自己发消息")
target = await db.users.get_by_id(peer)
if target is None:
raise HTTPException(status_code=404, detail="接收人不存在")
msg = await db.dm.send(user["id"], peer, content,
ip=request.client.host if request.client else "")
await write_audit(db, action="dm.send", resource="dm_message", resource_id=msg["id"],
detail=f"to={peer}", user=user, request=request)
return {"ok": True, "message": msg}
# ===========================================================================
# 我发布的(发布者视角,0041
# ===========================================================================
# 注:/tasks/mine 和 /jobs/mine 已移至参数化路由之前注册,避免被 {task_id}/{job_id} 抢先匹配