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

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

+36 -20
查看文件
@@ -9,13 +9,13 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import defer
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.exceptions import BadRequestError, ForbiddenError, NotFoundError
from app.core.response import PageResult
from app.core.tplus_lock import TPlusBusyError, sync_lock
from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
from app.schemas.tplus_workbench import WorkCreateIn
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
from app.services.tplus.task_service import _source_database, new_batch_no
from app.services.tplus.task_service import _source_database, history_source_database, new_batch_no
RULE_VERSION = "2026-10-30.2"
logger = logging.getLogger(__name__)
@@ -30,6 +30,17 @@ def digest(data):
).hexdigest()
def _work_source_database(session):
"""工作区使用本地已存资料;SQL Server 读取开关只约束真正的源采集。"""
try:
return _source_database()
except BadRequestError:
source = history_source_database(session)
if source is None:
raise BadRequestError("尚无可用的源账套,请先配置账套并完成源数据采集") from None
return source
def latest_facts(session, source):
"""每个事实键只读取最近版本,历史行保持可追溯。"""
ids = (
@@ -47,7 +58,7 @@ def latest_facts(session, source):
def save_fact(session, payload, actor_id):
"""与导入共用锁,乐观版本不匹配时拒绝覆盖。"""
source = _source_database()
source = _work_source_database(session)
try:
with sync_lock(session):
session.rollback()
@@ -88,7 +99,7 @@ def fact_out(fact):
def list_facts(session, query):
source = _source_database()
source = history_source_database(session)
ids = (
select(func.max(TPlusFact.id))
.where(TPlusFact.source_database == source)
@@ -115,8 +126,13 @@ def list_facts(session, query):
def queue_work(session, payload: WorkCreateIn, actor_id):
"""HTTP 只排队;同幂等键的不同意图必须被拒绝。"""
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, actor_id),
"tplus:import" if payload.kind.endswith("_APPLY") else "tplus:preview")
values = payload.model_dump(mode="json", exclude={"request_key"})
source = _source_database()
source = _work_source_database(session)
key = str(payload.request_key)
found = session.scalar(select(TPlusWorkRun).where(TPlusWorkRun.request_key == key))
if found:
@@ -131,15 +147,10 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
preview = get_run(session, payload.preview_no, source)
if preview.kind != "MASTER_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的基础导入预览")
if payload.kind.startswith("BUSINESS_"):
from app.models import User
from app.services.payroll_access import ensure_payroll_writer
ensure_payroll_writer(session.get(User, actor_id))
if payload.kind == "BUSINESS_APPLY":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的正式业务导入预览")
if payload.kind == "BUSINESS_APPLY":
preview = get_run(session, payload.preview_no, source)
if preview.kind != "BUSINESS_PREVIEW" or preview.status not in ("SUCCEEDED", "PARTIAL"):
raise BadRequestError("必须选择已完成的正式业务导入预览")
if payload.replay_no:
previous = get_run(session, payload.replay_no, source)
if previous.kind != "CALC_PREVIEW" or previous.status not in ("SUCCEEDED", "PARTIAL"):
@@ -165,7 +176,7 @@ def queue_work(session, payload: WorkCreateIn, actor_id):
def get_run(session, number, source=None):
source = source or _source_database()
source = source or history_source_database(session)
run = session.scalar(
select(TPlusWorkRun)
.where(TPlusWorkRun.run_no == number, TPlusWorkRun.source_database == source)
@@ -194,7 +205,7 @@ def work_out(run):
def list_work(session, query):
conditions = [TPlusWorkRun.source_database == _source_database()]
conditions = [TPlusWorkRun.source_database == history_source_database(session)]
if query.kind:
conditions.append(TPlusWorkRun.kind == query.kind)
total = session.scalar(select(func.count()).select_from(TPlusWorkRun).where(*conditions))
@@ -248,7 +259,7 @@ def list_lines(session, number, query):
def fact_history(session, fact_id, query):
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
fact = session.get(TPlusFact, fact_id)
if fact is None or fact.source_database != _source_database():
if fact is None or fact.source_database != history_source_database(session):
raise NotFoundError("补充资料不存在")
conditions = [
TPlusFact.source_database == fact.source_database,
@@ -276,7 +287,7 @@ def source_choices(session, query):
from app.services.tplus.snapshots import source_statement
try:
statement = source_statement(session, _source_database(), query.entity)
statement = source_statement(session, history_source_database(session), query.entity)
except WorkbenchError:
raise BadRequestError("请先完整读取相关源数据") from None
if query.search:
@@ -409,8 +420,13 @@ def execute_next(session):
run.status, run.started_at = "RUNNING", datetime.now()
session.commit()
try:
if run.source_database != _source_database():
if run.source_database != _work_source_database(session):
raise WorkbenchError("SOURCE_CHANGED")
from app.models import User
from app.services.authorization_service import require_permission
require_permission(session, session.get(User, run.requested_by),
"tplus:import" if run.kind.endswith("_APPLY") else "tplus:preview")
if run.kind.startswith("BUSINESS_"):
from app.services.tplus.business_import import execute_business
@@ -430,7 +446,7 @@ def execute_next(session):
run = get_run(session, run.run_no, run.source_database)
run.status, run.finished_at = "FAILED", datetime.now()
# 不让驱动异常、SQL 或原始数据进入响应。
run.error_code = str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
run.error_code = "TASK_AUTHORIZATION_REVOKED" if isinstance(exc, ForbiddenError) else str(exc) if isinstance(exc, WorkbenchError) else "EXECUTION_ERROR"
logger.error(
"工作区任务失败 | run_no=%s | exception_type=%s", run.run_no, type(exc).__name__
)