From 076f96af7471e654101c9e80b632f6fae16e9eb5 Mon Sep 17 00:00:00 2001 From: Pine Date: Thu, 27 Aug 2026 19:05:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(tasks):=20=E9=98=B6=E6=AE=B5A=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=A0=B8=E5=BF=83=20=E2=80=94=E2=80=94=20=E5=A4=9A?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E4=BB=BB=E5=8A=A1=E5=AD=97=E6=AE=B5=20+=20?= =?UTF-8?q?=E6=8E=A5=E5=8D=95=E8=B5=84=E6=A0=BC=E5=88=A4=E5=AE=9A=20+=20?= =?UTF-8?q?=E5=90=84=E6=A8=A1=E5=BC=8F=E9=99=90=E9=A2=9D=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=20+=20=E5=85=AC=E5=BC=80=E5=B9=BF=E5=9C=BA/=E8=AF=A6=E6=83=85c?= =?UTF-8?q?an=5Faccept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- alembic/versions/0020_task_multi_mode.py | 48 +++++++++++++++++ app/api/routers/rbac_opc.py | 27 ++++++++-- app/infrastructure/models.py | 20 ++++++- app/infrastructure/repositories.py | 48 ++++++++++++++--- app/services/task_service.py | 69 +++++++++++++++++++++++- 5 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 alembic/versions/0020_task_multi_mode.py diff --git a/alembic/versions/0020_task_multi_mode.py b/alembic/versions/0020_task_multi_mode.py new file mode 100644 index 0000000..282a93b --- /dev/null +++ b/alembic/versions/0020_task_multi_mode.py @@ -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) diff --git a/app/api/routers/rbac_opc.py b/app/api/routers/rbac_opc.py index f56bc41..b13c6dd 100644 --- a/app/api/routers/rbac_opc.py +++ b/app/api/routers/rbac_opc.py @@ -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 diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 38a2109..5ffa72e 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -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="") diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index c92ee88..035c7db 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -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} diff --git a/app/services/task_service.py b/app/services/task_service.py index 1cb395e..a860e1d 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -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,