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

@@ -0,0 +1,66 @@
"""计提公式模板数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models import BusinessRole, CalcTemplateDefinition
def get_by_id(session: Session, template_id: int, *, for_update: bool = False) -> CalcTemplateDefinition | None:
"""按主键查询;删除校验走行锁。"""
if for_update:
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.id == template_id).with_for_update()
return session.execute(stmt).scalar_one_or_none()
return session.get(CalcTemplateDefinition, template_id)
def get_by_code(session: Session, template_code: str) -> CalcTemplateDefinition | None:
"""按编码查询模板。编码是岗位绑定与参数解析的锚点。"""
stmt = select(CalcTemplateDefinition).where(CalcTemplateDefinition.template_code == template_code)
return session.execute(stmt).scalar_one_or_none()
def list_all(session: Session) -> list[CalcTemplateDefinition]:
"""全部模板(含停用,供管理列表展示引用状态)。"""
stmt = select(CalcTemplateDefinition).order_by(
CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id
)
return list(session.execute(stmt).scalars())
def list_active(session: Session) -> list[CalcTemplateDefinition]:
"""启用模板,供岗位表单下拉。"""
stmt = (
select(CalcTemplateDefinition)
.where(CalcTemplateDefinition.status == 1)
.order_by(CalcTemplateDefinition.sort_order, CalcTemplateDefinition.id)
)
return list(session.execute(stmt).scalars())
def count_roles_by_codes(session: Session, codes: list[str]) -> dict[str, int]:
"""批量统计各模板被岗位引用的数量,用于删除保护与列表展示。"""
if not codes:
return {}
rows = session.execute(
select(BusinessRole.calc_template, func.count(BusinessRole.id))
.where(BusinessRole.calc_template.in_(codes))
.group_by(BusinessRole.calc_template)
).all()
return {code: int(count) for code, count in rows}
def create(session: Session, template: CalcTemplateDefinition) -> CalcTemplateDefinition:
"""新增模板。只 flush 以取得自增 id。"""
session.add(template)
session.flush()
return template
def delete(session: Session, template: CalcTemplateDefinition) -> None:
"""删除模板。调用方须先完成保护规则校验。"""
session.delete(template)
session.flush()
@@ -0,0 +1,59 @@
"""监控查询只使用固定字段与有界窗口;事务由服务层负责。"""
from sqlalchemy import func, select
from app.models.monitor import MonitorEvent, MonitorRequestMinute, MonitorSnapshot
def snapshots(session, start, end):
return session.scalars(
select(MonitorSnapshot)
.where(MonitorSnapshot.minute >= start, MonitorSnapshot.minute < end)
.order_by(MonitorSnapshot.minute)
.limit(10082)
).all()
def request_minutes(session, start, end, route=None):
statement = select(MonitorRequestMinute).where(
MonitorRequestMinute.minute >= start, MonitorRequestMinute.minute < end
)
if route:
statement = statement.where(MonitorRequestMinute.route == route)
return session.scalars(
statement.order_by(MonitorRequestMinute.minute.desc()).limit(20001)
).all()
def events(session, query, start, end):
filters = [MonitorEvent.last_seen >= start, MonitorEvent.first_seen < end]
if query.kind:
filters.append(MonitorEvent.kind == query.kind)
if query.request_id:
filters.append(MonitorEvent.request_id == query.request_id)
if query.route:
filters.append(MonitorEvent.route == query.route)
if query.active_only:
filters.extend([MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None)])
total = session.scalar(select(func.count()).select_from(MonitorEvent).where(*filters)) or 0
rows = session.scalars(
select(MonitorEvent)
.where(*filters)
.order_by(MonitorEvent.last_seen.desc(), MonitorEvent.id.desc())
.offset(query.offset)
.limit(query.page_size)
).all()
return rows, total
def active_events(session):
return session.scalars(
select(MonitorEvent)
.where(MonitorEvent.kind == "HEALTH", MonitorEvent.resolved_at.is_(None))
.order_by(MonitorEvent.last_seen.desc())
.limit(100)
).all()
def event(session, event_id):
return session.get(MonitorEvent, event_id)
+19 -1
View File
@@ -1,6 +1,6 @@
"""T+ 管理库访问;只 flush,事务由 service 管理。"""
from sqlalchemy import func, select, update
from sqlalchemy import case, func, select, update
from sqlalchemy.orm import Session, defer
from app.models import TPlusSyncBatch, TPlusSyncError
@@ -129,6 +129,24 @@ def status_counts(session: Session) -> dict[str, int]:
)
def progress_reports(session: Session, numbers):
if not numbers:
return {}
rows = session.execute(select(TPlusSyncBatch.batch_no,
TPlusSyncBatch.audit_result_json["progress"],
TPlusSyncBatch.audit_result_json["observed_entities"],
).where(TPlusSyncBatch.batch_no.in_(numbers)))
return {number: {"progress": progress, "observed_entities": observed or []}
for number, progress, observed in rows}
def current_batch(session: Session):
return session.scalar(select(TPlusSyncBatch).order_by(
case((TPlusSyncBatch.status == "RUNNING", 0), (TPlusSyncBatch.status == "QUEUED", 1), else_=2),
TPlusSyncBatch.id.desc(),
).limit(1))
def latest_audit(session: Session):
"""最近完成的核验;返回批次供查询层过滤报告。"""
return session.scalar(
+1 -1
View File
@@ -37,7 +37,7 @@ def get_role_codes(user: User) -> list[str]:
依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义),
故此处遍历不会触发 N+1 查询。
"""
return [link.role.role_code for link in user.user_roles if link.role is not None]
return [link.role.role_code for link in user.user_roles if link.role is not None and link.role.status == 1]
def touch_last_login(session: Session, user: User, moment: datetime) -> None: