From d754d6ca4e0fb575e9f52d971be1b4413e80ae8f Mon Sep 17 00:00:00 2001 From: PineHomePC Date: Sun, 23 Aug 2026 23:56:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=9A=E5=8A=A1=E5=B1=82=20services?= =?UTF-8?q?=20+=20=E9=A2=86=E5=9F=9F=E5=B1=82=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 领域层 domain/rules.py:RBAC 判定规则纯函数(role/permission/scope),接口层守卫改调领域规则 - 业务层 services/task_service.py:统一任务状态机(grab/bid/deliver/win/review),修复 deliver 无状态预检缺陷 - rbac_opc 抢单/投标/交付改走 TaskService --- app/api/routers/rbac_opc.py | 21 +++++------- app/domain/__init__.py | 2 ++ app/domain/rules.py | 44 +++++++++++++++++++++++++ app/rbac.py | 47 ++++++++++---------------- app/services/__init__.py | 2 ++ app/services/task_service.py | 64 ++++++++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 app/domain/__init__.py create mode 100644 app/domain/rules.py create mode 100644 app/services/__init__.py create mode 100644 app/services/task_service.py diff --git a/app/api/routers/rbac_opc.py b/app/api/routers/rbac_opc.py index bcc25bc..0dd4be1 100644 --- a/app/api/routers/rbac_opc.py +++ b/app/api/routers/rbac_opc.py @@ -168,10 +168,9 @@ async def opc_grab( db: Database = Depends(get_db), actor: dict = Depends(require_roles("opc_member")), ): - task = await db.tasks.get(task_id) - if task is None or task["status"] != "published" or task["mode"] != "grab": - raise HTTPException(status_code=400, detail="任务不可抢单") - updated = await db.tasks.set_status(task_id, "in_progress") + from ...services.task_service import TaskService + + updated = await TaskService(db).grab(task_id, actor) await write_audit(db, action="task.grab", resource="task", resource_id=task_id, detail=actor.get("username"), user=actor, request=request) return updated @@ -185,11 +184,9 @@ async def opc_bid( db: Database = Depends(get_db), actor: dict = Depends(require_roles("opc_member")), ): - task = await db.tasks.get(task_id) - if task is None or task["status"] != "published" or task["mode"] != "bid": - raise HTTPException(status_code=400, detail="任务不可投标") - bid = await db.bids.create(task_id, actor["id"], actor.get("nickname") or actor["username"], - req.quote, req.plan) + from ...services.task_service import TaskService + + bid = await TaskService(db).bid(task_id, actor, req.quote, req.plan) await write_audit(db, action="task.bid", resource="bid", resource_id=bid["id"], user=actor, request=request) return bid @@ -202,9 +199,9 @@ async def opc_deliver( db: Database = Depends(get_db), actor: dict = Depends(require_roles("opc_member")), ): - updated = await db.tasks.set_status(task_id, "delivered") - if updated is None: - raise HTTPException(status_code=404, detail="Task not found") + from ...services.task_service import TaskService + + updated = await TaskService(db).deliver(task_id, actor) await write_audit(db, action="task.deliver", resource="task", resource_id=task_id, user=actor, request=request) return updated diff --git a/app/domain/__init__.py b/app/domain/__init__.py new file mode 100644 index 0000000..c61881c --- /dev/null +++ b/app/domain/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""领域层:核心业务模型与规则(纯函数/领域服务),不依赖接口/业务/基础设施。""" diff --git a/app/domain/rules.py b/app/domain/rules.py new file mode 100644 index 0000000..b401c25 --- /dev/null +++ b/app/domain/rules.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""领域层 · RBAC 判定规则(纯函数,不依赖 HTTP/数据库)。 + +四层架构:领域层定义核心业务模型与规则;接口层守卫(rbac.py 的 Depends +工厂)只负责调用这些规则并映射为 HTTP 异常,不承载判定语义。 +""" +from __future__ import annotations + +# 数据范围层级(越靠前权限越大) +SCOPE_ORDER = ("province", "city", "district") + + +def role_allowed(user: dict, *roles: str) -> bool: + """当前用户业务角色是否 ∈ 允许角色集合。""" + return user.get("role") in set(roles) + + +def sub_role_allowed(user: dict, *sub_roles: str) -> bool: + """当前用户子角色是否 ∈ 允许子角色集合(如 op_super_admin)。""" + return user.get("sub_role") in set(sub_roles) + + +def permission_granted(user: dict, perm: str) -> bool: + """当前用户是否拥有权限码 perm。""" + return perm in user.get("permissions", []) + + +def is_government(user: dict) -> bool: + return user.get("role") == "government" + + +def scope_level_ok(user: dict, min_level: str | None) -> bool: + """政务数据范围层级校验:不低于 min_level。""" + if not user.get("region_id"): + return False + if not min_level or not user.get("scope_level"): + return True + cur = user["scope_level"] + return cur in SCOPE_ORDER and SCOPE_ORDER.index(cur) <= SCOPE_ORDER.index(min_level) + + +def region_in_scope(user: dict, region_id: str | None) -> bool: + """目标区域是否落在当前用户数据范围内。""" + return bool(region_id) and region_id in user.get("scope_region_ids", []) diff --git a/app/rbac.py b/app/rbac.py index e9b1224..da27841 100644 --- a/app/rbac.py +++ b/app/rbac.py @@ -1,8 +1,7 @@ # -*- coding: utf-8 -*- -"""RBAC 依赖工厂:角色 / 权限 / 数据范围守卫 + 审计写入。 +"""接口层 RBAC 守卫工厂:角色 / 权限 / 数据范围守卫 + 审计写入。 -依赖以 ``Depends(get_current_user)`` 为前置,返回已装配 role/权限/ -scope_region_ids 的当前用户 dict;不满足即抛 403。 +判定语义在领域层 ``domain/rules.py``;本文件只把规则映射为 HTTP 异常。 """ from __future__ import annotations @@ -11,73 +10,63 @@ from collections.abc import Callable from fastapi import Depends, HTTPException, Request from .api.dependencies import get_current_user +from .domain.rules import ( + is_government, + permission_granted, + region_in_scope, + role_allowed, + scope_level_ok, + sub_role_allowed, +) from .infrastructure.repositories import Database -# 数据范围层级(越靠前权限越大) -_SCOPE_ORDER = ("province", "city", "district") - def require_roles(*roles: str) -> Callable: """要求当前用户业务角色 ∈ roles;否则 403。""" - allowed = set(roles) - def dep(user: dict = Depends(get_current_user)) -> dict: - if user.get("role") not in allowed: + if not role_allowed(user, *roles): raise HTTPException(status_code=403, detail="Forbidden: insufficient role") return user - return dep def require_sub_roles(*sub_roles: str) -> Callable: """要求当前用户子角色 ∈ sub_roles(如 op_super_admin);否则 403。""" - allowed = set(sub_roles) - def dep(user: dict = Depends(get_current_user)) -> dict: - if user.get("sub_role") not in allowed: + if not sub_role_allowed(user, *sub_roles): raise HTTPException(status_code=403, detail="Forbidden: insufficient sub-role") return user - return dep def require_permission(perm: str) -> Callable: """要求当前用户拥有权限码 ``perm``;否则 403。""" def dep(user: dict = Depends(get_current_user)) -> dict: - if perm not in user.get("permissions", []): + if not permission_granted(user, perm): raise HTTPException(status_code=403, detail=f"Forbidden: missing permission {perm}") return user - return dep def require_scope(min_level: str | None = None) -> Callable: - """政务数据范围守卫:要求用户为 government 且有区域,且不低于 min_level。""" + """政务数据范围守卫:要求 government 且区域层级不低于 min_level。""" def dep(user: dict = Depends(get_current_user)) -> dict: - if user.get("role") != "government": + if not is_government(user): raise HTTPException(status_code=403, detail="Forbidden: not government") if not user.get("region_id"): raise HTTPException(status_code=403, detail="Forbidden: no region scope") - if min_level and user.get("scope_level"): - if _SCOPE_ORDER.index(user["scope_level"]) > _SCOPE_ORDER.index(min_level): - raise HTTPException( - status_code=403, - detail=f"Forbidden: scope below {min_level}", - ) + if not scope_level_ok(user, min_level): + raise HTTPException(status_code=403, detail=f"Forbidden: scope below {min_level}") return user - return dep def scope_covers(region_id: str | None) -> Callable: """要求 ``region_id`` 落在当前用户数据范围内;否则 403。""" def dep(user: dict = Depends(get_current_user)) -> dict: - if not region_id: - raise HTTPException(status_code=403, detail="Forbidden: target has no region") - if region_id not in user.get("scope_region_ids", []): + if not region_in_scope(user, region_id): raise HTTPException(status_code=403, detail="Forbidden: out of data scope") return user - return dep diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..f104ffd --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""业务层:业务逻辑编排与用例。只依赖领域层与基础设施层,不依赖接口层。""" diff --git a/app/services/task_service.py b/app/services/task_service.py new file mode 100644 index 0000000..d7fa862 --- /dev/null +++ b/app/services/task_service.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +"""业务层 · 任务状态机(抢单 / 投标 / 交付 / 中标 / 验收)。 + +业务层只处理业务逻辑;不感知 HTTP 之外的框架细节。依赖基础设施层 +Repository(经 Database 门面),不反向依赖接口层。 +""" +from __future__ import annotations + +from fastapi import HTTPException + +from ..infrastructure.repositories import Database + + +class TaskService: + """统一任务状态流转:grab/bid/deliver/win/review。""" + + def __init__(self, db: Database): + self.db = db + + async def grab(self, task_id: str, actor: dict) -> dict: + """抢单:仅 published + grab 模式可抢,抢后置 in_progress。""" + task = await self.db.tasks.get(task_id) + if task is None or task["status"] != "published" or task["mode"] != "grab": + raise HTTPException(status_code=400, detail="任务不可抢单") + return await self.db.tasks.set_status(task_id, "in_progress") + + async def bid(self, task_id: str, actor: dict, quote: int, plan: str) -> dict: + """投标:仅 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="任务不可投标") + return await self.db.bids.create( + task_id, actor["id"], actor.get("nickname") or actor["username"], + quote, plan, + ) + + async def deliver(self, task_id: str, actor: dict) -> dict: + """交付:任务存在且处于进行中(in_progress)才可交付 → delivered。 + + 补齐原实现「无任何状态预检」的缺陷,防随意交付。 + """ + task = await self.db.tasks.get(task_id) + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + if task["status"] != "in_progress": + raise HTTPException(status_code=400, detail="任务未在进行中,无法交付") + return await self.db.tasks.set_status(task_id, "delivered") + + async def enterprise_submit(self, task_id: str) -> dict: + """企业提交审核:pending 任务 → published。""" + return await self.db.tasks.set_status(task_id, "published") + + async def win_bid(self, task_id: str, bid_id: str) -> dict: + """企业评标中标:bid → win,task → in_progress(双状态联动)。""" + bid = await self.db.bids.get(bid_id) + if bid is None or bid["task_id"] != task_id: + raise HTTPException(status_code=404, detail="竞标不存在") + await self.db.bids.set_status(bid_id, "win") + return await self.db.tasks.set_status(task_id, "in_progress") + + async def review(self, task_id: str, accept: bool) -> dict: + """企业验收:accept → completed;reject → in_progress。""" + target = "completed" if accept else "in_progress" + return await self.db.tasks.set_status(task_id, target)