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

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

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

影响范围:后端全部模块、数据库迁移 V22-V28
验证:uv run ruff check 通过;922 个单元测试全部通过;
集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
这个提交包含在:
liuwenxin 于 2026-11-03 11:20:55 +08:00 提交
1 父节点 02fb06e0af
当前提交 1461a8d113
149 个文件已更改
+8736 -368

没有与搜索条件相匹配的文件

+14 -1
查看文件
@@ -10,16 +10,20 @@
"""
from app.models.approval import ApprovalOrder, ApprovalStep
from app.models.authorization import AuthorizationSetting, UserPermissionOverride
from app.models.base import Base, TimestampMixin
from app.models.business import Business, BusinessItem, BusinessStatusLog, SecondaryAllocation
from app.models.business_role import BusinessRole
from app.models.calc_template import CalcTemplateDefinition
from app.models.customer import Customer, OwnershipChangeLog
from app.models.login_log import LoginLog
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
from app.models.operation_log import OperationLog
from app.models.param import ParamChangeBatch, ParamDefinition, ParamValue
from app.models.payroll import PayrollAllocation, PayrollInput, PayrollMonth
from app.models.performance_ledger import PerformanceLedger
from app.models.personnel import Personnel, PersonnelPlan, PersonnelRegion
from app.models.probation import ProbationDeferral, ProbationPayment
from app.models.product import Product
from app.models.rating import CustomerRatingGrade
from app.models.receipt import Receipt
@@ -27,7 +31,7 @@ from app.models.role import Permission, Role, RolePermission
from app.models.settlement import AdjustmentOrder, MonthlySettlement, SettlementItem
from app.models.shipment import Shipment, ShipmentItem
from app.models.system_setting import SystemSetting
from app.models.tplus_automation import TPlusAutomationState
from app.models.tplus_automation import TPlusAutomationConfig, TPlusAutomationState
from app.models.tplus_sync import (
TPlusSyncBatch,
TPlusSyncError,
@@ -47,14 +51,19 @@ __all__ = [
"AdjustmentOrder",
"ApprovalOrder",
"ApprovalStep",
"AuthorizationSetting",
"Base",
"Business",
"BusinessItem",
"BusinessRole",
"BusinessStatusLog",
"CalcTemplateDefinition",
"Customer",
"CustomerRatingGrade",
"LoginLog",
"MonitorEvent",
"MonitorRequestMinute",
"MonitorSnapshot",
"MonthlySettlement",
"OperationLog",
"OwnershipChangeLog",
@@ -69,6 +78,8 @@ __all__ = [
"Personnel",
"PersonnelPlan",
"PersonnelRegion",
"ProbationDeferral",
"ProbationPayment",
"Product",
"Receipt",
"Role",
@@ -78,6 +89,7 @@ __all__ = [
"Shipment",
"ShipmentItem",
"SystemSetting",
"TPlusAutomationConfig",
"TPlusAutomationState",
"TPlusFact",
"TPlusSourceHead",
@@ -90,5 +102,6 @@ __all__ = [
"TPlusWorkRun",
"TimestampMixin",
"User",
"UserPermissionOverride",
"UserRole",
]
+28
查看文件
@@ -0,0 +1,28 @@
"""授权版本锁及旧版授权结构;V26 后不再使用个人覆盖或全局制作人绑定。"""
from sqlalchemy import ForeignKey, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
class UserPermissionOverride(Base, TimestampMixin):
__tablename__ = "user_permission_overrides"
__table_args__ = mysql_table_args(
"用户权限覆盖表(个人禁止优先)",
UniqueConstraint("user_id", "permission_id", name="uk_user_permission_overrides_user_permission"),
Index("idx_user_permission_overrides_permission_id", "permission_id"),
)
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
user_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="CASCADE"), comment="系统用户ID")
permission_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, ForeignKey("permissions.id", ondelete="RESTRICT"), comment="权限ID")
effect: Mapped[str] = mapped_column(String(8), comment="个人覆盖:ALLOW允许 DENY禁止")
class AuthorizationSetting(Base, TimestampMixin):
__tablename__ = "authorization_settings"
__table_args__ = mysql_table_args("授权设置表(单例与并发修改锁)")
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="单例主键ID")
maker_user_id: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="RESTRICT"), comment="现任工资制作人账号ID")
revision: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1", comment="全局授权版本")
updated_by: Mapped[int | None] = mapped_column(BIGINT_UNSIGNED, ForeignKey("users.id", ondelete="SET NULL"), comment="最近修改人ID")
+52
查看文件
@@ -0,0 +1,52 @@
"""计提公式模板定义模型。
把一期代码内注册表升级为数据库配置:模板名称、说明、参数绑定可在页面维护,
公式执行骨架仍是受控枚举(formula_type),不允许自由表达式,避免注入与算错。
岗位角色 `business_roles.calc_template` 绑定模板编码,编码创建后不可修改。
"""
from sqlalchemy import JSON, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class CalcTemplateDefinition(Base, TimestampMixin):
"""计提公式模板定义表。内置模板随迁移初始化,页面可新增自定义模板。"""
__tablename__ = "calc_template_definitions"
__table_args__ = mysql_table_args("计提公式模板定义表(页面可维护,骨架受控)")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
template_code: Mapped[str] = mapped_column(
String(32),
nullable=False,
unique=True,
comment="模板编码,创建后不可修改;岗位表 business_roles.calc_template 引用",
)
template_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="模板名称")
formula_type: Mapped[str] = mapped_column(
String(32),
nullable=False,
comment="公式骨架类型:linear_decay/fixed_profit/fixed_sales/manager_share/prepay_fixed,创建后不可修改",
)
params_json: Mapped[dict] = mapped_column(
JSON,
nullable=False,
comment="骨架参数角色到参数编码的 JSON 映射,如 {start:..., slope:...}",
)
description: Mapped[str | None] = mapped_column(String(255), comment="模板说明")
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
is_system: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="是否系统模板:1内置(禁删、骨架与编码锁定) 0自定义",
)
+79
查看文件
@@ -0,0 +1,79 @@
"""监控数据独立保存,不关联工资或审计表;仅这些新表按七天保留。"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, Float, Index, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
class MonitorSnapshot(Base):
__tablename__ = "monitor_snapshots"
__table_args__ = mysql_table_args(
"分钟级运行监控快照",
UniqueConstraint("instance_id", "minute", name="uk_monitor_snapshots_instance_minute"),
Index("idx_monitor_snapshots_minute", "minute"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
minute: Mapped[datetime] = mapped_column(DateTime, comment="采集分钟,北京时间")
sample_count: Mapped[int] = mapped_column(Integer, comment="实际采样次数")
resources: Mapped[dict] = mapped_column(JSON, comment="资源均值与采集范围,不含配置")
requests: Mapped[dict] = mapped_column(JSON, comment="请求汇总与固定耗时直方图")
class MonitorRequestMinute(Base):
__tablename__ = "monitor_request_minutes"
__table_args__ = mysql_table_args(
"接口分钟级运行统计",
UniqueConstraint(
"instance_id", "minute", "method", "route", name="uk_monitor_request_minutes_endpoint"
),
Index("idx_monitor_request_minutes_minute", "minute"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
minute: Mapped[datetime] = mapped_column(DateTime, comment="请求分钟,北京时间")
method: Mapped[str] = mapped_column(String(8), comment="HTTP方法,固定集合")
route: Mapped[str] = mapped_column(String(256), comment="路由模板,不含原始地址及参数")
metrics: Mapped[dict] = mapped_column(JSON, comment="请求数、状态分类与固定直方图")
class MonitorEvent(Base):
__tablename__ = "monitor_events"
__table_args__ = mysql_table_args(
"脱敏诊断事件与异常恢复记录",
Index("idx_monitor_events_last_seen", "last_seen"),
Index("idx_monitor_events_request_id", "request_id"),
Index("idx_monitor_events_fingerprint", "fingerprint", "resolved_at"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
instance_id: Mapped[str] = mapped_column(String(32), comment="应用实例标识")
kind: Mapped[str] = mapped_column(String(24), comment="事件类型,固定集合")
code: Mapped[str] = mapped_column(String(64), comment="稳定错误码")
component: Mapped[str] = mapped_column(String(128), comment="所属组件或日志模块")
level: Mapped[str] = mapped_column(String(16), comment="异常级别")
message: Mapped[str] = mapped_column(String(256), comment="通用说明,不保存内部异常文本")
fingerprint: Mapped[str | None] = mapped_column(String(64), comment="同类健康异常合并键")
request_id: Mapped[str | None] = mapped_column(String(32), comment="请求关联编号")
method: Mapped[str | None] = mapped_column(String(8), comment="HTTP方法")
route: Mapped[str | None] = mapped_column(String(256), comment="路由模板")
status_code: Mapped[int | None] = mapped_column(Integer, comment="HTTP状态码")
duration_ms: Mapped[float | None] = mapped_column(Float, comment="请求耗时,毫秒")
batch_no: Mapped[str | None] = mapped_column(String(32), comment="相关同步批次编号")
first_seen: Mapped[datetime] = mapped_column(DateTime, comment="首次发生时间")
last_seen: Mapped[datetime] = mapped_column(DateTime, comment="最近发生时间")
occurrences: Mapped[int] = mapped_column(Integer, comment="发生或持续采样次数")
resolved_at: Mapped[datetime | None] = mapped_column(
DateTime, comment="恢复时间,仅用于健康异常"
)
+14 -1
查看文件
@@ -10,7 +10,7 @@
from datetime import date
from decimal import Decimal
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy import JSON, Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -62,9 +62,22 @@ class Personnel(Base, TimestampMixin, TPlusSyncMixin):
server_default="0",
comment="是否试用期:1是 0否(试用期绩效照算不发放)",
)
probation_start_date: Mapped[date | None] = mapped_column(
Date, comment="试用期开始日(含当日)"
)
regularization_date: Mapped[date | None] = mapped_column(
Date, comment="转正生效日(当日不再属于试用期)"
)
resignation_date: Mapped[date | None] = mapped_column(
Date, comment="离职生效日(当日起不计基本工资)"
)
salary_standard: Mapped[Decimal | None] = mapped_column(
Numeric(12, 2), comment="月工资标准(元;T+同步不覆盖)"
)
salary_history_json: Mapped[list | None] = mapped_column(
JSON,
comment="工资标准、岗位及任职资料生效版本",
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
+81
查看文件
@@ -0,0 +1,81 @@
"""试用期缓发与结清记录,引用固化台账,不使用人员当前开关重写历史。"""
from datetime import datetime
from decimal import Decimal
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Numeric, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, mysql_table_args
class ProbationDeferral(Base):
__tablename__ = "probation_deferrals"
__table_args__ = mysql_table_args(
"试用期缓发台账(每个正式台账版本一条)",
UniqueConstraint("ledger_id", name="uk_probation_deferrals_ledger_id"),
Index("idx_probation_deferrals_personnel_id_year", "personnel_id", "year"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
ledger_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("performance_ledgers.id", ondelete="RESTRICT"),
nullable=False,
comment="原核算台账版本ID",
)
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原计提年度")
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次计提或应付变化月份")
accrued: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="本次试用期计提额,可负"
)
eligible: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="满足起付及权重的本次应付差额,可负"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
)
class ProbationPayment(Base):
__tablename__ = "probation_payments"
__table_args__ = mysql_table_args(
"试用期年终及后续结清记录(工资生成事务内固化)",
UniqueConstraint(
"settlement_id",
"personnel_id",
"year",
name="uk_probation_payments_settlement_person_year",
),
UniqueConstraint("adjustment_order_id", name="uk_probation_payments_adjustment_order_id"),
Index("idx_probation_payments_personnel_id_year", "personnel_id", "year"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
settlement_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("monthly_settlements.id", ondelete="CASCADE"),
nullable=True,
comment="月结ID(当月调整单结清时为空)",
)
adjustment_order_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("adjustment_orders.id", ondelete="RESTRICT"),
nullable=True,
comment="已生成工资后的结清调整单ID",
)
personnel_id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, nullable=False, comment="归属人员ID")
year: Mapped[str] = mapped_column(String(4), nullable=False, comment="原累计年度")
month: Mapped[str] = mapped_column(String(7), nullable=False, comment="本次实际支付月份")
amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, comment="本次补发或扣回金额"
)
snapshot_json: Mapped[dict] = mapped_column(
JSON, nullable=False, comment="计提、起付权重、门槛及历史补发依据"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=False, comment="创建时间"
)
+3 -1
查看文件
@@ -8,7 +8,7 @@
from decimal import Decimal
from sqlalchemy import Numeric, String, UniqueConstraint
from sqlalchemy import JSON, Numeric, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import (
@@ -56,3 +56,5 @@ class Product(Base, TimestampMixin, TPlusSyncMixin):
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0停用"
)
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
source_trade_json: Mapped[dict | None] = mapped_column(JSON, comment="最近有效发货数量、含税成交价及源单据快照")
quantity_price_manual: Mapped[int] = mapped_column(TINYINT_TYPE, nullable=False, server_default="0", comment="数量单价人工保护:1人工维护 0随源更新")
+12 -2
查看文件
@@ -131,17 +131,27 @@ class SettlementItem(Base):
String(16), comment="月工资口径快照:INCLUDE/DEDUCT/EXCLUDE"
)
net_amount: Mapped[Decimal | None] = mapped_column(
Numeric(14, 2), comment="净工资 = 绩效±基本工资(按口径;试用期 0)"
Numeric(14, 2),
comment="净工资 = 绩效±基本工资(按口径;试用期为基本工资+正常应付+年度差额,缓发部分不发)"
)
is_probation: Mapped[int] = mapped_column(
TINYINT_TYPE,
nullable=False,
server_default="0",
comment="试用期:1绩效照算不发放(net=0,超年累计记 note)0否",
comment="试用期:1缓发绩效不发(基本工资正常发,超年累计记 note)0否",
)
probation_note: Mapped[str | None] = mapped_column(
String(255), comment="试用期说明(年累计超阈值部分年底一次性结算)"
)
normal_payable_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, server_default="0", comment="当月正常实付绩效"
)
deferred_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, server_default="0", comment="当月试用期缓发变化"
)
probation_payment_sum: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, server_default="0", comment="本月年度补发或扣回"
)
snapshot_json: Mapped[dict | None] = mapped_column(
JSON, comment="个人明细快照(台账行引用列表)"
)
+18 -2
查看文件
@@ -2,10 +2,26 @@
from datetime import datetime
from sqlalchemy import DateTime, Integer, String
from sqlalchemy import DateTime, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, Base, TimestampMixin, mysql_table_args
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
class TPlusAutomationConfig(Base, TimestampMixin):
"""页面保存的源级开关,与长任务心跳表分开,切换不争用采集状态行。"""
__tablename__ = "tplus_automation_configs"
__table_args__ = mysql_table_args(
"T+自动采集设置(不含连接凭据)",
UniqueConstraint("source_key", name="uk_tplus_automation_configs_source_key"),
)
id: Mapped[int] = mapped_column(BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID")
source_key: Mapped[str] = mapped_column(String(64), comment="源连接身份摘要")
source_database: Mapped[str] = mapped_column(String(128), comment="源账套")
enabled: Mapped[int] = mapped_column(TINYINT_TYPE, comment="自动采集是否开启:1开启0关闭")
version: Mapped[int] = mapped_column(Integer, comment="设置版本号,防止并发覆盖")
updated_by: Mapped[int] = mapped_column(BIGINT_UNSIGNED, comment="最近修改账号ID")
class TPlusAutomationState(Base, TimestampMixin):