e831fdd34e
- 迁移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
1545 lines
75 KiB
Python
1545 lines
75 KiB
Python
# -*- 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):
|
||
"""按叠加制能力集合判定(capabilities:opc_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-forget,IM 服务不可用不阻断)
|
||
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="岗位大厅列表(published;enterprise 可见自己全部)")
|
||
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} 抢先匹配
|