feat(tasks): 阶段A后端核心 —— 多模式任务字段 + 接单资格判定 + 各模式限额守卫 + 公开广场/详情can_accept

- Task 增扩展列:publisher_role/visibility/c_visible/park_public/assign_type/assign_park_id/assign_opc_id/park_id/park_released/bid_quota/win_quota/eligibility(JSON)/settle_mode(默认operator_escrow)
- TaskRepository._to_dict/create/update 同步新字段;迁移 0020_task_multi_mode
- TaskService.can_accept(task,user):仅被指派/园区专属/指派园区未发单 + eligibility(证书/地域/性别/工时/领域/专长/案例) 判定,返回 ok/reasons
- 抢单(grab)与投标(bid)接入 can_accept 守卫;bid 加报名上限与重复报名校验
- /opc/tasks 公开广场改为 published+c_visible;/opc/tasks/{id} 详情同;登录后附带 can_accept/accept_reasons(经 optional_current_user)
This commit is contained in:
Pine
2026-08-27 19:05:18 +08:00
parent 11598abcc1
commit 076f96af74
5 changed files with 198 additions and 14 deletions
+48
View File
@@ -0,0 +1,48 @@
"""多端任务体系:任务扩展列(发布方/可见性/指派/园区/竞标配额/接单资格/托管)
Revision ID: 0020_task_multi_mode
Revises: 0019_content_review
Create Date: 2026-08-27
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0020_task_multi_mode"
down_revision = "0019_content_review"
branch_labels = None
depends_on = None
def upgrade() -> None:
cols = [
("publisher_role", sa.String(), "server_default", ""),
("visibility", sa.String(), "server_default", "public"),
("c_visible", sa.Boolean(), "server_default", sa.text("1")),
("park_public", sa.Boolean(), "server_default", sa.text("0")),
("assign_type", sa.String(), "server_default", ""),
("assign_park_id", sa.String(), "nullable", True),
("assign_opc_id", sa.String(), "nullable", True),
("park_id", sa.String(), "nullable", True),
("park_released", sa.Boolean(), "server_default", sa.text("0")),
("bid_quota", sa.Integer(), "server_default", "0"),
("win_quota", sa.Integer(), "server_default", "0"),
("eligibility", sa.Text(), "server_default", "{}"),
("settle_mode", sa.String(), "server_default", "operator_escrow"),
]
for name, typ, kw, val in cols:
if kw == "nullable":
op.add_column("tasks", sa.Column(name, typ, nullable=val))
elif kw == "server_default":
op.add_column("tasks", sa.Column(name, typ, nullable=False, server_default=val))
def downgrade() -> None:
for name, *_ in [
("publisher_role",), ("visibility",), ("c_visible",), ("park_public",),
("assign_type",), ("assign_park_id",), ("assign_opc_id",), ("park_id",),
("park_released",), ("bid_quota",), ("win_quota",), ("eligibility",),
("settle_mode",),
]:
op.drop_column("tasks", name)
+22 -5
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from ..dependencies import get_db
from ..dependencies import get_db, optional_current_user
from ..schemas.opc import BidRequest, ProfileUpdate, FinanceRecordCreate, TaskClaimRequest, TaskAssignRequest, TaskRecommendRequest, TaskSelectRecommendRequest
from ...rbac import require_roles, write_audit
from ...infrastructure.repositories import Database, new_id, utcnow_iso
@@ -58,10 +58,20 @@ async def opc_agent(
@router.get("/tasks", summary="任务广场(已发布平台任务,公开可见)")
async def opc_task_square(
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
"""任务广场公开可见:返回已发布(published)任务,按展示优先级降序。查看无需登录。"""
items = await db.tasks.list_published()
"""任务广场公开可见:返回「待接单 published + C端可见(c_visible)」任务,按展示优先级降序。
查看无需登录;登录后对每个任务标注 can_accept / accept_reasons(接单资格判定)。"""
from ...services.task_service import TaskService
svc = TaskService(db)
items = [t for t in await db.tasks.list_published() if t.get("status") == "published" and t.get("c_visible")]
items.sort(key=lambda t: -(t.get("display_priority") or 0))
if user:
for it in items:
acc = await svc.can_accept(it, user)
it["can_accept"] = acc["ok"]
it["accept_reasons"] = acc["reasons"]
return {"items": items}
@@ -69,11 +79,18 @@ async def opc_task_square(
async def opc_task_detail(
task_id: str,
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
"""任务详情,公开可见:仅返回已发布任务;否则 404。"""
"""任务详情,公开可见:仅返回已发布 + C端可见任务;否则 404。登录后附 can_accept。"""
from ...services.task_service import TaskService
item = await db.tasks.get(task_id)
if item is None or item.get("status") != "published":
if item is None or item.get("status") != "published" or not item.get("c_visible"):
raise HTTPException(status_code=404, detail="任务不存在")
if user:
acc = await TaskService(db).can_accept(item, user)
item["can_accept"] = acc["ok"]
item["accept_reasons"] = acc["reasons"]
return item
+18 -2
View File
@@ -233,6 +233,20 @@ class Task(Base):
publisher_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) # 发包方(用户)
publisher_org_id: Mapped[str | None] = mapped_column(ForeignKey("organizations.id"), nullable=True)
publisher_name: Mapped[str] = mapped_column(String, default="")
# ── 多端任务体系扩展字段 ──
publisher_role: Mapped[str] = mapped_column(String, default="") # enterprise/operator/park
visibility: Mapped[str] = mapped_column(String, default="public") # public/assigned_only/c_visible
c_visible: Mapped[bool] = mapped_column(Boolean, default=True) # 是否进 C 端任务广场
park_public: Mapped[bool] = mapped_column(Boolean, default=False) # 是否公有任务(各园区大屏可见/本园可接)
assign_type: Mapped[str] = mapped_column(String, default="") # opc/park(指派模式)
assign_park_id: Mapped[str | None] = mapped_column(String, nullable=True) # 指派给某园区
assign_opc_id: Mapped[str | None] = mapped_column(String, nullable=True) # 指派给某 OPC
park_id: Mapped[str | None] = mapped_column(String, nullable=True) # 归属园区(园区发布/专属)
park_released: Mapped[bool] = mapped_column(Boolean, default=False) # 园区内已发单(园区企业可抢)
bid_quota: Mapped[int] = mapped_column(Integer, default=0) # 竞标报名人数上限(0=不限)
win_quota: Mapped[int] = mapped_column(Integer, default=0) # 竞标中标人数(默认1)
eligibility: Mapped[str] = mapped_column(Text, default="{}") # 接单资格条件(JSON 集)
settle_mode: Mapped[str] = mapped_column(String, default="operator_escrow") # 资金托管方式
created_at: Mapped[str] = mapped_column(String, default="")
updated_at: Mapped[str] = mapped_column(String, default="")
@@ -299,7 +313,8 @@ class ContentItem(Base):
body: Mapped[str] = mapped_column(Text, default="")
publisher_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True)
# 资讯富字段:封面/图集/视频/外链/发布人
cover: Mapped[str] = mapped_column(String, default="") # 封面图 URL
cover: Mapped[str] = mapped_column(String, default="") # 加宽封面图 URL3.35:1
cover_small: Mapped[str] = mapped_column(String, default="") # 小封面图 URL1:1,可选)
images_json: Mapped[str] = mapped_column(Text, default="[]") # 图集(JSON url 数组)
video: Mapped[str] = mapped_column(String, default="") # 视频 URL
video_cover: Mapped[str] = mapped_column(String, default="") # 视频封面
@@ -311,7 +326,8 @@ class ContentItem(Base):
is_public: Mapped[bool] = mapped_column(Boolean, default=True) # 是否公开(公开=C端,否则仅园区大屏)
source: Mapped[str] = mapped_column(String, default="operator") # operator 运营端 | carrier 园区端
tenant_id: Mapped[str] = mapped_column(String, default="") # 园区端发布所属园区
status: Mapped[str] = mapped_column(String, default="draft") # draft/published/offline
priority: Mapped[int] = mapped_column(Integer, default=0) # 优先级 0-999 越大越靠前(C端排序)
status: Mapped[str] = mapped_column(String, default="draft") # draft/pending/published/offlinepending=待审核)
created_at: Mapped[str] = mapped_column(String, default="")
updated_at: Mapped[str] = mapped_column(String, default="")
+42 -6
View File
@@ -1033,6 +1033,13 @@ class TaskRepository:
"doing_at": t.doing_at,
"publisher_id": t.publisher_id,
"publisher_org_id": t.publisher_org_id, "publisher_name": t.publisher_name,
"publisher_role": t.publisher_role, "visibility": t.visibility,
"c_visible": t.c_visible, "park_public": t.park_public,
"assign_type": t.assign_type, "assign_park_id": t.assign_park_id,
"assign_opc_id": t.assign_opc_id, "park_id": t.park_id,
"park_released": t.park_released,
"bid_quota": t.bid_quota, "win_quota": t.win_quota,
"eligibility": t.eligibility, "settle_mode": t.settle_mode,
"created_at": t.created_at, "updated_at": t.updated_at,
}
@@ -1080,6 +1087,19 @@ class TaskRepository:
publisher_id=fields.get("publisher_id"),
publisher_org_id=fields.get("publisher_org_id"),
publisher_name=fields.get("publisher_name", ""),
publisher_role=fields.get("publisher_role", ""),
visibility=fields.get("visibility", "public"),
c_visible=fields.get("c_visible", True),
park_public=fields.get("park_public", False),
assign_type=fields.get("assign_type", ""),
assign_park_id=fields.get("assign_park_id"),
assign_opc_id=fields.get("assign_opc_id"),
park_id=fields.get("park_id"),
park_released=fields.get("park_released", False),
bid_quota=fields.get("bid_quota", 0),
win_quota=fields.get("win_quota", 0),
eligibility=fields.get("eligibility", "{}"),
settle_mode=fields.get("settle_mode", "operator_escrow"),
created_at=now, updated_at=now)
self.session.add(t)
await self.session.commit()
@@ -1094,6 +1114,9 @@ class TaskRepository:
"description", "tags", "mode", "budget_min", "budget_max", "deadline",
"delivery_days", "headcount", "exclusive", "display_priority",
"publisher_id", "publisher_org_id", "publisher_name",
"publisher_role", "visibility", "c_visible", "park_public",
"assign_type", "assign_park_id", "assign_opc_id", "park_id",
"park_released", "bid_quota", "win_quota", "eligibility", "settle_mode",
):
if key in fields:
setattr(t, key, fields.get(key))
@@ -1269,8 +1292,8 @@ class ContentRepository:
# 富字段白名单(images 为 list,落库转 json
_FIELDS = {"type", "title", "summary", "body", "status", "publisher_id",
"cover", "images", "video", "video_cover", "link",
"publisher_name", "publisher_avatar", "card_mode", "is_public", "source", "tenant_id"}
"cover", "cover_small", "images", "video", "video_cover", "link",
"publisher_name", "publisher_avatar", "card_mode", "is_public", "source", "tenant_id", "priority"}
def _to_dict(self, c: ContentItem) -> dict:
try:
@@ -1280,16 +1303,16 @@ class ContentRepository:
return {
"id": c.id, "type": c.type, "title": c.title, "summary": c.summary,
"body": c.body, "publisher_id": c.publisher_id, "status": c.status,
"cover": c.cover, "images": images, "video": c.video, "video_cover": c.video_cover,
"cover": c.cover, "cover_small": c.cover_small, "images": images, "video": c.video, "video_cover": c.video_cover,
"link": c.link, "publisher_name": c.publisher_name, "publisher_avatar": c.publisher_avatar,
"read_count": c.read_count, "card_mode": c.card_mode, "is_public": bool(c.is_public),
"source": c.source, "tenant_id": c.tenant_id,
"source": c.source, "tenant_id": c.tenant_id, "priority": c.priority or 0,
"created_at": c.created_at, "updated_at": c.updated_at,
}
async def list(self, ctype: str | None = None, status: str | None = None,
public_only: bool = False, tenant_id: str | None = None) -> list[dict]:
q = select(ContentItem).order_by(ContentItem.created_at.desc())
q = select(ContentItem).order_by(ContentItem.priority.desc(), ContentItem.created_at.desc())
if ctype:
q = q.where(ContentItem.type == ctype)
if status:
@@ -1307,7 +1330,8 @@ class ContentRepository:
c = ContentItem(id=new_id("cont"), type=fields.get("type", "news"),
title=fields.get("title", ""), summary=fields.get("summary", ""),
body=fields.get("body", ""), publisher_id=fields.get("publisher_id"),
cover=fields.get("cover", ""), images_json=images_json,
cover=fields.get("cover", ""), cover_small=fields.get("cover_small", ""),
images_json=images_json,
video=fields.get("video", ""), video_cover=fields.get("video_cover", ""),
link=fields.get("link", ""),
publisher_name=fields.get("publisher_name", ""),
@@ -1316,6 +1340,7 @@ class ContentRepository:
is_public=bool(fields.get("is_public", True)),
source=fields.get("source", "operator"),
tenant_id=fields.get("tenant_id", ""),
priority=int(fields.get("priority", 0) or 0),
status=fields.get("status", "draft"), created_at=now, updated_at=now)
self.session.add(c)
await self.session.commit()
@@ -1330,6 +1355,17 @@ class ContentRepository:
await self.session.commit()
return self._to_dict(c)
async def approve(self, content_id: str) -> dict | None:
"""审核通过:置为已发布并公开(C 端可见)。"""
c = await self.session.get(ContentItem, content_id)
if c is None:
return None
c.status = "published"
c.is_public = True
c.updated_at = utcnow_iso()
await self.session.commit()
return self._to_dict(c)
async def update(self, content_id: str, fields: dict) -> dict | None:
"""更新内容(仅传入字段;含富字段 封面/图集/视频/发布人)。"""
patch = {k: v for k, v in fields.items() if v is not None and k in self._FIELDS}
+68 -1
View File
@@ -6,6 +6,8 @@ Repository(经 Database 门面),不反向依赖接口层。
"""
from __future__ import annotations
import json
from fastapi import HTTPException
from ..infrastructure.repositories import Database
@@ -17,6 +19,58 @@ class TaskService:
def __init__(self, db: Database):
self.db = db
async def can_accept(self, task: dict | None, actor: dict | None) -> dict:
"""接单资格判定:可见性(仅被指派) + 归属园区(专属) + eligibility 条件集。
返回 {ok, reasons[]}reasons 为空即通过。
"""
if not task:
return {"ok": False, "reasons": ["任务不存在"]}
reasons: list[str] = []
actor = actor or {}
full = (await self.db.users.get_by_id(actor["id"])) if actor.get("id") else {}
user_park = full.get("park_company_id") or full.get("park_id") or actor.get("park_id")
uid = actor.get("id")
# 仅被指派可见 → 只有被指派对象(OPC 或园区)可接
if uid and task.get("visibility") == "assigned_only":
target = task.get("assign_opc_id") or task.get("assign_park_id")
if target and target != uid and target != user_park:
reasons.append("仅被指派方可接")
# 园区专属任务 → 仅本园
if task.get("park_id") and user_park != task.get("park_id"):
reasons.append("任务为本园区专属")
# 指派给园区但尚未发单 → 需园区端先操作
if task.get("assign_type") == "park" and not task.get("park_released") and task.get("assign_park_id") != user_park:
reasons.append("由园区分派")
# eligibility 条件集
try:
cond = json.loads(task.get("eligibility") or "{}")
except Exception:
cond = {}
if not isinstance(cond, dict):
cond = {}
if cond.get("opc_certified") and not full.get("opc_certified") and not full.get("opc_cert"):
reasons.append("需 OPC 认证")
if cond.get("region") and cond["region"] != "any" and (full.get("region_id") or "") != cond["region"]:
reasons.append("地域不符")
if cond.get("gender") and full.get("gender") != cond["gender"]:
reasons.append("不符合性别要求")
if cond.get("years_min") and (int(full.get("exp_years") or 0) < int(cond["years_min"])):
reasons.append(f"需从业 {cond['years_min']} 年以上")
if cond.get("field") and cond["field"] not in (full.get("fields") or []) and cond["field"] not in (full.get("field") or ""):
reasons.append("领域不符")
if cond.get("skill") and cond["skill"] not in (full.get("skills") or []) and cond["skill"] not in (full.get("skill") or ""):
reasons.append("专长不符")
if cond.get("case_req") and (int(full.get("case_count") or 0) < int(cond["case_req"])):
reasons.append(f"{cond['case_req']} 例服务案例")
reasons = [r for r in reasons if r]
return {"ok": not reasons, "reasons": reasons}
async def grab(self, task_id: str, actor: dict) -> dict:
"""抢单(兼容旧入口):published + grab → claimed,记录接单流水。"""
return await self.claim_by_id(task_id, actor, source="grab")
@@ -45,6 +99,9 @@ class TaskService:
raise HTTPException(status_code=400, detail="该任务为独占,已被接单")
if (task.get("headcount") or 0) > 0 and active >= task["headcount"]:
raise HTTPException(status_code=400, detail="该任务接单人数已达上限")
acc = await self.can_accept(task, actor)
if not acc["ok"]:
raise HTTPException(status_code=403, detail="不满足接单条件:" + "".join(acc["reasons"]))
updated = await self.db.tasks.claim(task_id, actor["id"])
await self.db.task_claims.create(
task_id, actor["id"],
@@ -127,10 +184,20 @@ class TaskService:
return await self.db.tasks.set_status(task_id, "completed")
async def bid(self, task_id: str, actor: dict, quote: int, plan: str) -> dict:
"""投标:仅 published + bid 模式可投。"""
"""投标:仅 published + bid 模式可投;校验报名人数上限 + 接单条件"""
task = await self.db.tasks.get(task_id)
if task is None or task["status"] != "published" or task["mode"] != "bid":
raise HTTPException(status_code=400, detail="任务不可投标")
acc = await self.can_accept(task, actor)
if not acc["ok"]:
raise HTTPException(status_code=403, detail="不符合报名条件:" + "".join(acc["reasons"]))
if task.get("bid_quota"):
existing = await self.db.bids.list_for_task(task_id)
if len(existing) >= task["bid_quota"]:
raise HTTPException(status_code=400, detail="该竞标报名人数已达上限")
for b in await self.db.bids.list_for_task(task_id):
if b.get("opc_id") == actor["id"]:
raise HTTPException(status_code=400, detail="你已报名该竞标")
return await self.db.bids.create(
task_id, actor["id"], actor.get("nickname") or actor["username"],
quote, plan,