feat(后端): 添加授权体系、系统监控与试用期计提

按 2026-11-02 需求补齐计划交付后端能力:
- 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝;
  管理员判断改为按数据库角色重新判定,防止权限码持有者绕过
- 系统监控:单进程采集资源、请求与异常指标,分钟级聚合,
  阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义
- 试用期计提:按回款日判定并写入快照,年度结清与调整单联动;
  人员模型补试用期、离职日期与工资版本历史字段
- 计提模板:公式骨架受控,页面维护名称与参数绑定
- T+ 采集:自动化配置表与进度查询

内置角色禁停用保护对齐模块注释;核算规则版本提为常量并
补充批量核算失败堆栈日志。

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
This commit is contained in:
liuwenxin committed 2026-11-03 11:20:55 +08:00
1 parent 02fb06e0af
commit 1461a8d113
149 files changed
+8736 -368

No files matched your search

+4
View File
@@ -58,6 +58,10 @@ class UserBrief(BaseModel):
roles: list[str] = Field(default_factory=list, description="角色编码列表")
can_write_payroll: bool = False
payroll_approval_enabled: bool = False
permissions: list[str] = Field(default_factory=list)
authorization_revision: int = 0
is_payroll_maker: bool = False
maker_required_permissions: list[str] = Field(default_factory=list)
class LoginOut(BaseModel):
+45
View File
@@ -0,0 +1,45 @@
"""授权请求版本化,禁止匿名覆盖他人的修改。"""
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class PermissionOverrideIn(BaseModel):
model_config = ConfigDict(extra="forbid")
permission_code: str = Field(min_length=1, max_length=128)
effect: Literal["ALLOW", "DENY"]
class AuthorizationPreviewIn(BaseModel):
model_config = ConfigDict(extra="forbid")
role_codes: list[str] = Field(default_factory=list, max_length=100)
# 保留空字段兼容旧客户端;不再允许个人允许/禁止覆盖角色授权。
overrides: list[PermissionOverrideIn] = Field(default_factory=list, max_length=0)
class UserAuthorizationIn(AuthorizationPreviewIn):
expected_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=500)
@field_validator("reason")
@classmethod
def reason_required(cls, value):
if not value.strip():
raise ValueError("修改原因不能为空")
return value.strip()
class RolePermissionsIn(BaseModel):
model_config = ConfigDict(extra="forbid")
permission_codes: list[str] = Field(max_length=300)
expected_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=500)
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
class AuthorizationSettingsIn(BaseModel):
model_config = ConfigDict(extra="forbid")
maker_user_id: int | None = Field(default=None, ge=1)
expected_revision: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=500)
_reason = field_validator("reason")(UserAuthorizationIn.reason_required.__func__)
+9
View File
@@ -107,6 +107,15 @@ class BusinessItemIn(BaseModel):
return _check_amount(value, "金额", AMOUNT_MAX)
@model_validator(mode="after")
def _derive_guided_cost(self):
from app.services.cost_rules import guided_total
total = guided_total(self.quantity, self.guided_price, self.cost_amount)
self.cost_amount = str(total) if total is not None else None
return self
class BusinessItemOut(BaseModel):
"""业务产品明细行响应。金额/数量一律 str。"""
+82
View File
@@ -0,0 +1,82 @@
"""计提公式模板的请求/响应模型。"""
import re
from pydantic import BaseModel, ConfigDict, Field, field_validator
# 模板编码与历史 TPL_ 编码一致:大写字母开头,大写字母/数字/下划线,3–32 位。
TEMPLATE_CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,31}$")
class TemplateParamOut(BaseModel):
"""模板骨架参数角色映射(角色 → 参数编码)。"""
role: str
param_code: str
class CalcTemplateOut(BaseModel):
"""模板列表/详情响应。"""
model_config = ConfigDict(from_attributes=True)
id: int
template_code: str
template_name: str
formula_type: str
params: dict[str, str]
description: str | None = None
sort_order: int = 0
status: int = 1
is_system: int = 0
role_count: int = 0
class CalcTemplateOptionOut(BaseModel):
"""岗位表单下拉项(仅启用模板,含骨架与参数绑定说明)。"""
code: str
name: str
formula_type: str
description: str
params: list[TemplateParamOut]
class FormulaOptionOut(BaseModel):
"""公式骨架下拉项(新增模板时选择,受控集合)。"""
code: str
name: str
description: str
roles: list[str]
class CalcTemplateCreateIn(BaseModel):
"""新增模板。编码与骨架创建后不可修改。"""
model_config = ConfigDict(str_strip_whitespace=True)
template_code: str = Field(min_length=3, max_length=32, description="模板编码,创建后不可修改")
template_name: str = Field(min_length=1, max_length=64, description="模板名称")
formula_type: str = Field(min_length=1, max_length=32, description="公式骨架类型")
params: dict[str, str] = Field(default_factory=dict, description="骨架参数角色到参数编码映射")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0)
@field_validator("template_code")
@classmethod
def _check_code(cls, value: str) -> str:
if not TEMPLATE_CODE_PATTERN.match(value):
raise ValueError("模板编码须以大写字母开头,仅可含大写字母、数字与下划线")
return value
class CalcTemplateUpdateIn(BaseModel):
"""编辑模板。编码与骨架锁定;参数绑定仍按骨架角色校验。"""
model_config = ConfigDict(str_strip_whitespace=True)
template_name: str = Field(min_length=1, max_length=64)
params: dict[str, str] = Field(default_factory=dict)
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0)
+23
View File
@@ -0,0 +1,23 @@
"""系数表与 Excel 导入共用的边界模型。"""
from datetime import date
from typing import Literal
from pydantic import BaseModel, Field
class CoefficientRowIn(BaseModel):
product_category: Literal["municipal_water", "rail_transit"]
customer_type: Literal["personal", "enterprise"]
stage: Literal["first", "linear", "after", "fixed"]
value: str = Field(min_length=1, max_length=64)
slope: str | None = Field(default=None, max_length=64)
boundary: int | None = Field(default=None, ge=1, le=3660)
class CoefficientChangeIn(BaseModel):
effective_date: date
base_date: date | None = None
remark: str = Field(default="系数表维护", max_length=255)
fingerprint: str = Field(min_length=64, max_length=64)
rows: list[CoefficientRowIn] = Field(min_length=1, max_length=16)
+14 -3
View File
@@ -9,8 +9,7 @@
重算总览/待办/预警。列表与模块筛选项同源返回(一次请求给全)。
待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策);
⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级,
不返回伪造的 0 条。
同步记录、读取开关和自动采集开关分别返回,不用当前开关覆盖历史采集结果。
"""
from datetime import date, datetime
@@ -80,7 +79,7 @@ class TodoBlock(BaseModel):
pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list)
unassigned_receipt_total: int = 0
unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list)
sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用")
sync_available: bool = Field(default=False, description="同步记录是否可查询")
sync_failure_total: int = 0
@@ -154,6 +153,17 @@ class ActivityPageOut(BaseModel):
modules: list[str] = Field(default_factory=list, description="全部模块名(筛选项)")
class SyncBlock(BaseModel):
"""源采集历史与当前运行配置,仅公开汇总状态。"""
available: bool = False
enabled: bool = False
automation_enabled: bool = False
latest_status: str | None = None
last_success_at: datetime | None = None
historical_failure_total: int = 0
class DashboardOut(BaseModel):
"""工作台三区块一次性响应(最近动态走独立分页端点)。"""
@@ -161,3 +171,4 @@ class DashboardOut(BaseModel):
overview: OverviewBlock
todos: TodoBlock
warnings: WarningBlock
sync: SyncBlock = Field(default_factory=SyncBlock)
+78
View File
@@ -0,0 +1,78 @@
"""监控接口只暴露固定白名单,不传输内部异常或连接配置。"""
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
from app.core.response import PageQuery
Status = Literal["normal", "warning", "error", "unknown"]
Hours = Annotated[
Literal[1, 24, 168],
BeforeValidator(lambda value: int(value) if value in ("1", "24", "168") else value),
]
class MonitorWindow(BaseModel):
hours: Hours = 1
end: datetime | None = None
route: str | None = Field(default=None, max_length=256)
class EventQuery(PageQuery):
hours: Hours = 24
end: datetime | None = None
route: str | None = Field(default=None, max_length=256)
kind: Literal["HEALTH", "REQUEST_ERROR", "SLOW_REQUEST", "RUNTIME_ERROR"] | None = None
request_id: str | None = Field(default=None, pattern=r"^[a-f0-9]{32}$")
active_only: bool = False
class EventOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
instance_id: str
kind: str
code: str
component: str
level: str
message: str
request_id: str | None = None
method: str | None = None
route: str | None = None
status_code: int | None = None
duration_ms: float | None = None
batch_no: str | None = None
first_seen: datetime
last_seen: datetime
occurrences: int
resolved_at: datetime | None = None
class DependencyOut(BaseModel):
name: str
status: Status
message: str
checked_at: datetime | None = None
duration_ms: float | None = None
class OverviewOut(BaseModel):
status: Status
environment: str
version: str
instance_id: str
uptime_seconds: int
collected_at: datetime | None
last_persisted_at: datetime | None
collection_status: Status
collection_message: str
resources: dict
requests: dict
dependencies: list[DependencyOut]
alerts: list[EventOut]
tasks: dict
dropped_events: int
retention_days: int = 7
-2
View File
@@ -191,8 +191,6 @@ class ParamPreviewIn(BaseModel):
@model_validator(mode="after")
def _check_dates(self) -> "ParamPreviewIn":
if self.receipt_date < self.ship_date:
raise ValueError("回款到账日不得早于发货日")
return self
+17 -1
View File
@@ -3,7 +3,7 @@
from datetime import date
from decimal import Decimal, InvalidOperation
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.core.response import PageQuery
@@ -46,6 +46,9 @@ class PersonnelOut(BaseModel):
manager_name: str | None = None
phone: str | None = None
hire_date: date | None = None
probation_start_date: date | None = None
regularization_date: date | None = None
resignation_date: date | None = None
is_probation: int = 0
salary_standard: str | None = None
status: int = 1
@@ -53,6 +56,7 @@ class PersonnelOut(BaseModel):
class PersonnelDetailOut(PersonnelOut):
salary_history_json: list[dict] | None = None
"""人员详情:附区域与拓展计划,供编辑抽屉一次回显。"""
regions: list[PersonnelRegionOut] = Field(default_factory=list)
@@ -60,6 +64,7 @@ class PersonnelDetailOut(PersonnelOut):
class PersonnelCreateIn(BaseModel):
salary_effective_date: date | None = None
"""新增人员。"""
model_config = ConfigDict(str_strip_whitespace=True)
@@ -69,6 +74,9 @@ class PersonnelCreateIn(BaseModel):
manager_id: int | None = Field(default=None, ge=1, description="直属营销经理ID,空=无")
phone: str | None = Field(default=None, max_length=20, description="联系电话")
hire_date: date | None = Field(default=None, description="入职日期,ISO 格式")
probation_start_date: date | None = None
regularization_date: date | None = None
resignation_date: date | None = None
is_probation: int = Field(default=0, ge=0, le=1, description="是否试用期:1是 0否")
salary_standard: str | None = Field(
default=None, description="月工资标准(元),字符串形式的数字"
@@ -100,6 +108,13 @@ class PersonnelCreateIn(BaseModel):
return value
@model_validator(mode="after")
def _validate_probation_dates(self):
if self.probation_start_date and self.regularization_date and self.regularization_date <= self.probation_start_date:
raise ValueError("转正生效日必须晚于试用期开始日")
return self
class PersonnelUpdateIn(PersonnelCreateIn):
"""编辑人员,全量提交、全量替换(不含状态,状态走独立接口)。"""
@@ -108,6 +123,7 @@ class PersonnelStatusIn(BaseModel):
"""在职/离职切换。"""
status: int = Field(ge=0, le=1, description="1在职 0离职")
effective_date: date | None = None
class RegionItemIn(BaseModel):
+14
View File
@@ -28,6 +28,8 @@ class ProductOut(BaseModel):
remark: str | None = None
create_time: datetime
update_time: datetime
source_trade_json: dict | None = None
quantity_price_manual: int = 0
class ProductCreateIn(BaseModel):
@@ -95,3 +97,15 @@ class ProductQuery(PageQuery):
keyword: str | None = Field(default=None, max_length=64, description="名称或规格,模糊匹配")
product_category: str | None = Field(default=None, max_length=32, description="按类别筛选")
status: int | None = Field(default=None, ge=0, le=1, description="状态筛选")
class ProductExtractIn(BaseModel):
item_ids: list[int] = Field(min_length=1, max_length=200)
fingerprint: str | None = Field(default=None, min_length=64, max_length=64)
@field_validator("item_ids")
@classmethod
def _ids(cls, value):
if any(v <= 0 for v in value) or len(value) != len(set(value)):
raise ValueError("明细ID必须为不重复的正整数")
return value
+1
View File
@@ -284,6 +284,7 @@ class DepartmentSummaryOut(BaseModel):
"""部门绩效工资汇总响应(FR-030)。"""
settlement_month: str
salary_rows: list[dict] = Field(default_factory=list, description="已生成工资的基本工资、正常绩效、缓发及年度补发快照")
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
teams: list[DepartmentTeamRow] = Field(default_factory=list)
unassigned: list[DepartmentMemberRow] = Field(
+3
View File
@@ -33,6 +33,7 @@ class RoleManageOut(RoleOut):
"""角色管理列表项:额外携带绑定用户数,供前端提示删除保护的原因。"""
user_count: int = 0
permission_count: int = 0
class RoleCreateIn(BaseModel):
@@ -44,6 +45,8 @@ class RoleCreateIn(BaseModel):
role_name: str = Field(min_length=1, max_length=64, description="角色名称")
description: str | None = Field(default=None, max_length=255)
sort_order: int = Field(default=0, ge=0, description="显示排序,越小越靠前")
permission_codes: list[str] | None = Field(default=None, max_length=300)
reason: str | None = Field(default=None, min_length=1, max_length=500)
@field_validator("role_code")
@classmethod
+4
View File
@@ -48,6 +48,10 @@ class SettlementItemOut(BaseModel):
net_amount: Decimal | None = None
is_probation: int
probation_note: str | None = None
normal_payable_sum: Decimal = Decimal("0")
deferred_sum: Decimal = Decimal("0")
probation_payment_sum: Decimal = Decimal("0")
snapshot_json: dict | None = None
class SettlementOut(BaseModel):
+28
View File
@@ -60,6 +60,24 @@ class TPlusErrorQuery(PageQuery):
entity_type: EntityType | None = None
class TPlusEntityProgress(BaseModel):
entity_type: str
total_records: int | None = None
processed_records: int | None = None
status: str = "WAITING"
class TPlusProgressOut(BaseModel):
phase: str
total_records: int | None = None
processed_records: int = 0
percentage: float | None = None
active_entity: str | None = None
updated_at: datetime | None = None
elapsed_seconds: int = 0
entities: list[TPlusEntityProgress] = Field(default_factory=list)
class TPlusBatchOut(BaseModel):
"""批次列表不包含原始数据、源配置或数据库异常。"""
@@ -79,6 +97,7 @@ class TPlusBatchOut(BaseModel):
error_summary: str | None = None
trigger_type: str = "MANUAL"
automatic_attempt: int = 0
progress: TPlusProgressOut | None = None
class TPlusBatchDetailOut(TPlusBatchOut):
@@ -105,8 +124,16 @@ class TPlusAutomationAlert(BaseModel):
batch_no: str | None = None
class TPlusAutomationUpdate(BaseModel):
enabled: bool = Field(strict=True)
expected_version: int = Field(ge=0, strict=True)
class TPlusAutomationOverview(BaseModel):
enabled: bool = False
version: int = 0
editable: bool = False
configuration_origin: str = "ENVIRONMENT"
weekday: int = 0
time: str = "02:00"
timezone: str = "Asia/Shanghai"
@@ -125,6 +152,7 @@ class TPlusOverviewOut(BaseModel):
configuration_message: str
status_counts: dict[str, int]
total_staged_records: int = 0
current_batch: TPlusBatchOut | None = None
compatibility_status: str = "PENDING"
business_import_enabled: bool = False
latest_audit: dict | None = None