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:
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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="") # 加宽封面图 URL(3.35:1)
|
||||
cover_small: Mapped[str] = mapped_column(String, default="") # 小封面图 URL(1: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/offline(pending=待审核)
|
||||
created_at: Mapped[str] = mapped_column(String, default="")
|
||||
updated_at: Mapped[str] = mapped_column(String, default="")
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user