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

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

+4 -4
查看文件
@@ -8,10 +8,8 @@ from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.config import get_settings
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service
from app.services.payroll_access import can_write_payroll
router = APIRouter(prefix="/auth", tags=["认证"])
@@ -67,13 +65,15 @@ def logout(
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
from app.services.authorization_service import session_info, user_session
authorization = session_info(user_session(current_user), current_user)
return ok(
CurrentUserOut(
id=current_user.id,
username=current_user.username,
real_name=current_user.real_name,
roles=get_role_codes(current_user),
can_write_payroll=can_write_payroll(current_user),
**authorization,
payroll_approval_enabled=get_settings().payroll_approval_enabled,
last_login_time=current_user.last_login_time,
)
+2 -2
查看文件
@@ -13,7 +13,7 @@ from app.api.deps import DbSession, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.business_role import BusinessRoleOptionOut
from app.services import business_role_service
from app.services.authorization_metadata import options
router = APIRouter(prefix="/business-roles", tags=["人员管理"])
@@ -31,4 +31,4 @@ def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[Busines
岗位规模有限(内置 + 自定义),一次取全量,不做分页。
"""
return ok(business_role_service.list_options(session))
return ok([BusinessRoleOptionOut.model_validate(row) for row in options(session, _, "business_roles")])
+1 -1
查看文件
@@ -108,7 +108,7 @@ def set_status(
) -> ApiResponse[PersonnelOut]:
"""在职/离职切换。离职人员不可再被业务与客户归属引用。"""
result = personnel_service.set_status(
session, personnel_id, payload.status, operator=current_user, meta=meta
session, personnel_id, payload.status, effective_date=payload.effective_date, operator=current_user, meta=meta
)
return ok(result, message="已设为在职" if payload.status == 1 else "已设为离职")
+31 -2
查看文件
@@ -3,8 +3,8 @@
产品是折算阈值的行级锚点(product_category 决定 30%/20% 折算阈值),
类别值域由后端校验 —— 前端下拉只是展示约束,后端校验是唯一防线。
**数据提取(POST /products/extract)暂不提供**:它依赖业务明细表
(V5 迁移),待业务管理任务完成后补充。
数据提取使用预览指纹校验、名称规格去重和单位分类冲突保护,
仅建立产品档案关联,不改写历史业务金额。
基础数据属业务操作,登录用户即可访问(区别于系统管理模块的 AdminUser);
写操作经 RequestMetaDep 带入请求元信息,由 service 层写入操作日志。
@@ -19,6 +19,7 @@ from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.product import (
ProductCreateIn,
ProductExtractIn,
ProductOut,
ProductQuery,
ProductStatusIn,
@@ -31,6 +32,34 @@ CurrentUser = Annotated[User, Depends(get_current_user)]
router = APIRouter(prefix="/products", tags=["产品管理"])
@router.get("/extract/candidates")
def extraction_candidates(session: DbSession, _: CurrentUser, keyword: str = "", page_num: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
from app.services.product_completion import candidates
return ok(candidates(session, keyword, page_num, page_size))
@router.post("/extract/preview")
def extraction_preview(payload: ProductExtractIn, session: DbSession, _: CurrentUser):
from app.services.product_completion import extraction_plan
return ok(extraction_plan(session, payload.item_ids))
@router.post("/extract")
def extraction_apply(payload: ProductExtractIn, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
from app.services.product_completion import extract
return ok(extract(session, payload, actor, meta))
@router.post("/{product_id}/restore-source")
def restore_source(product_id: int, session: DbSession, actor: CurrentUser, meta: RequestMetaDep):
from app.services.product_completion import restore_source as restore
return ok(restore(session, product_id, actor, meta))
@router.get("", response_model=ApiResponse[PageResult[ProductOut]], summary="产品列表")
def list_products(
session: DbSession,
+22
查看文件
@@ -0,0 +1,22 @@
"""表单参照选项,不能由此读取薪酬、联系方式或完整业务对象。"""
from typing import Annotated
from fastapi import APIRouter, Query
from app.api.deps import DbSession, LoginUser
from app.core.response import ok
from app.services.authorization_metadata import options, payroll_options
router = APIRouter(prefix="/meta", tags=["业务参照"])
@router.get("/payroll-input/{business_id}")
def payroll_metadata(business_id: int, session: DbSession, actor: LoginUser):
return ok(payroll_options(session, actor, business_id))
@router.get("/options")
def metadata_options(session: DbSession, actor: LoginUser, resource: str,
business_id: Annotated[int | None, Query(ge=1)] = None,
search: Annotated[str | None, Query(max_length=64)] = None):
return ok(options(session, actor, resource, business_id=business_id, search=search))
+43
查看文件
@@ -3,6 +3,8 @@
from typing import Annotated
from fastapi import APIRouter, Path, Query
from fastapi.responses import Response
from pydantic import BaseModel, Field
from app.api.deps import DbSession, LoginUser, PayrollWriter, RequestMetaDep
from app.core.response import PageQuery, ok
@@ -12,6 +14,47 @@ from app.services import payroll_calculation, payroll_inputs, payroll_months, se
router = APIRouter(prefix="/payroll", tags=["正式工资核算"])
class ProbationAdjustmentIn(BaseModel):
personnel_id: int = Field(ge=1)
year: str = Field(pattern=r"^20\d{2}$")
fingerprint: str = Field(min_length=64, max_length=64)
reason: str = Field(min_length=1, max_length=255)
@router.post("/probation/adjustments")
def probation_adjustment(payload: ProbationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.probation_service import adjustment as apply
return ok(apply(session, payload, actor, meta))
@router.get("/probation")
def probation_preview(session: DbSession, _: LoginUser, month: str, personnel_id: int | None = None):
from app.services import probation_service
return ok(probation_service.preview(session, month, personnel_id))
@router.get("/probation/export")
def probation_export(session: DbSession, _: LoginUser, month: str):
from app.services import probation_service
return Response(probation_service.export(session, month),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="probation_balances.xlsx"'})
@router.get("/salary-export/{month}")
def salary_export(month: str, session: DbSession, _: LoginUser):
from app.core.month import validate_month
from app.services.salary_completion import export_settlement
validate_month(month)
return Response(export_settlement(session, month),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="monthly_salary.xlsx"'})
@router.get("/months/{month}")
def month_state(
month: Annotated[str, Path(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")],
@@ -0,0 +1,56 @@
"""角色权限配置与用户角色分配;旧制作人设置只保留兼容提示。"""
from fastapi import APIRouter
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ok
from app.schemas.authorization import (
AuthorizationPreviewIn,
AuthorizationSettingsIn,
RolePermissionsIn,
UserAuthorizationIn,
)
from app.services import authorization_management as service
from app.services.permission_catalog import CATALOG
router = APIRouter(prefix="/system", tags=["系统管理 · 权限"])
@router.get("/permissions/catalog")
def catalog(session: DbSession, actor: AdminUser):
service.require_admin(session, actor)
return ok(list(CATALOG.values()))
@router.get("/roles/{role_id}/permissions")
def role_permissions(role_id: int, session: DbSession, actor: AdminUser):
return ok(service.role_permissions(session, role_id))
@router.put("/roles/{role_id}/permissions")
def save_role(role_id: int, payload: RolePermissionsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_role(session, role_id, payload, actor, meta))
@router.get("/users/{user_id}/authorization")
def user_authorization(user_id: int, session: DbSession, actor: AdminUser):
return ok(service.user_authorization(session, user_id))
@router.post("/users/{user_id}/authorization/preview")
def preview(user_id: int, payload: AuthorizationPreviewIn, session: DbSession, actor: AdminUser):
return ok(service.preview(session, user_id, payload))
@router.put("/users/{user_id}/authorization")
def save_user(user_id: int, payload: UserAuthorizationIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_user(session, user_id, payload, actor, meta))
@router.get("/authorization-settings")
def settings(session: DbSession, actor: AdminUser):
return ok(service.settings_out(session))
@router.put("/authorization-settings")
def save_settings(payload: AuthorizationSettingsIn, session: DbSession, actor: AdminUser, meta: RequestMetaDep):
return ok(service.save_settings(session, payload, actor, meta))
+1 -1
查看文件
@@ -60,7 +60,7 @@ def list_template_options(session: DbSession, _: AdminUser) -> ApiResponse[list[
定义在 `/{role_id}` 之前:静态路径优先,避免 "meta" 被当作路径参数解析。
"""
return ok(business_role_service.list_template_options())
return ok(business_role_service.list_template_options(session))
@router.get("/{role_id}", response_model=ApiResponse[BusinessRoleOut], summary="岗位详情")
@@ -0,0 +1,70 @@
"""计提公式模板管理接口(管理员)。"""
from typing import Annotated
from fastapi import APIRouter, Path
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.core.response import ApiResponse, ok
from app.schemas.calc_template import (
CalcTemplateCreateIn,
CalcTemplateOut,
CalcTemplateUpdateIn,
FormulaOptionOut,
)
from app.services import calc_template_service
router = APIRouter(prefix="/calc-templates", tags=["系统管理 · 公式模板"])
@router.get("", response_model=ApiResponse[list[CalcTemplateOut]], summary="模板列表")
def list_templates(session: DbSession, _: AdminUser) -> ApiResponse[list[CalcTemplateOut]]:
"""取全部模板(含停用与岗位引用计数)。"""
return ok(calc_template_service.list_templates(session).records)
@router.get("/meta/formulas", response_model=ApiResponse[list[FormulaOptionOut]], summary="公式骨架下拉")
def list_formula_options(_: AdminUser) -> ApiResponse[list[FormulaOptionOut]]:
"""取受控公式骨架集合(新增模板时选择)。"""
return ok([FormulaOptionOut.model_validate(row) for row in calc_template_service.list_formula_options()])
@router.post("", response_model=ApiResponse[CalcTemplateOut], summary="新增模板")
def create_template(
session: DbSession,
current_user: AdminUser,
payload: CalcTemplateCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CalcTemplateOut]:
"""新增公式模板。编码唯一、骨架受控、参数绑定按骨架角色校验。"""
return ok(
calc_template_service.create_template(session, payload, operator=current_user, meta=meta),
message="模板已创建",
)
@router.put("/{template_id}", response_model=ApiResponse[CalcTemplateOut], summary="编辑模板")
def update_template(
session: DbSession,
current_user: AdminUser,
template_id: Annotated[int, Path(ge=1)],
payload: CalcTemplateUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[CalcTemplateOut]:
"""编辑模板名称/说明/排序;自定义模板可调整参数绑定。"""
return ok(
calc_template_service.update_template(session, template_id, payload, operator=current_user, meta=meta),
message="已保存",
)
@router.delete("/{template_id}", response_model=ApiResponse[None], summary="删除模板")
def delete_template(
session: DbSession,
current_user: AdminUser,
template_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除模板。系统模板禁删;被岗位引用禁删。"""
calc_template_service.delete_template(session, template_id, operator=current_user, meta=meta)
return ok(message="模板已删除")
+37
查看文件
@@ -0,0 +1,37 @@
"""系数表查询、维护与工作簿导入导出,复用计提参数权限。"""
from datetime import date
from fastapi import APIRouter, File, UploadFile
from fastapi.responses import Response
from app.api.deps import DbSession, LoginUser, RequestMetaDep
from app.core.response import ok
from app.schemas.coefficient import CoefficientChangeIn
from app.services import coefficient_service
router = APIRouter(prefix="/coefficients", tags=["计提系数表"])
@router.get("")
def catalog(session: DbSession, _: LoginUser, as_of: date | None = None):
return ok(coefficient_service.catalog(session, as_of))
@router.put("")
def save(payload: CoefficientChangeIn, session: DbSession, actor: LoginUser, meta: RequestMetaDep):
return ok(coefficient_service.save(session, payload, actor, meta))
@router.get("/export")
def export(session: DbSession, _: LoginUser, as_of: date | None = None):
return Response(
coefficient_service.workbook(session, as_of),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="coefficients.xlsx"'},
)
@router.post("/import-preview")
def import_preview(session: DbSession, _: LoginUser, file: UploadFile = File(...)):
return ok(coefficient_service.import_preview(session, file.file.read(2 * 1024 * 1024 + 1)))
+56
查看文件
@@ -0,0 +1,56 @@
"""系统监控只读接口;全部复用管理员依赖,不提供运维写操作。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query, Request
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from app.api.deps import AdminUser, DbSession
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.monitor import EventOut, EventQuery, MonitorWindow, OverviewOut
from app.services import monitor_service
router = APIRouter(prefix="/monitor", tags=["系统管理 · 系统监控"])
@router.get("/overview", response_model=ApiResponse[OverviewOut], summary="健康总览")
def overview(request: Request, session: DbSession, _: AdminUser):
return ok(monitor_service.overview(session, request.app.state.monitor))
@router.get("/trends", response_model=ApiResponse[dict], summary="分钟级监控趋势")
def trends(
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
):
return ok(monitor_service.trends(session, request.app.state.monitor, query))
@router.get("/interfaces", response_model=ApiResponse[dict], summary="接口性能排行")
def interfaces(
request: Request, session: DbSession, _: AdminUser, query: Annotated[MonitorWindow, Query()]
):
return ok(monitor_service.interfaces(session, request.app.state.monitor, query))
@router.get("/events", response_model=ApiResponse[PageResult[EventOut]], summary="诊断事件")
def events(session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]):
return ok(monitor_service.events(session, query))
@router.get("/events/{event_id}", response_model=ApiResponse[EventOut], summary="诊断事件详情")
def event(session: DbSession, _: AdminUser, event_id: Annotated[int, Path(ge=1)]):
return ok(monitor_service.event(session, event_id))
@router.get("/diagnostic", summary="下载脱敏诊断摘要")
def diagnostic(
request: Request, session: DbSession, _: AdminUser, query: Annotated[EventQuery, Query()]
):
return JSONResponse(
jsonable_encoder(monitor_service.diagnostic(session, request.app.state.monitor, query)),
headers={
"Content-Disposition": 'attachment; filename="monitor-diagnostic.json"',
"Cache-Control": "no-store",
},
)
+10 -1
查看文件
@@ -8,6 +8,8 @@ from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, WorkbenchRead
from app.api.v1.system.tplus_workbench import router as workbench_router
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.tplus import (
TPlusAutomationOverview,
TPlusAutomationUpdate,
TPlusBatchDetailOut,
TPlusBatchOut,
TPlusBatchQuery,
@@ -17,7 +19,7 @@ from app.schemas.tplus import (
TPlusOverviewOut,
TPlusRetryIn,
)
from app.services.tplus import query_service, task_service
from app.services.tplus import automation_config, query_service, task_service
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
router.include_router(workbench_router)
@@ -30,6 +32,13 @@ def overview(session: DbSession, _: WorkbenchReader):
return ok(query_service.overview(session))
@router.put("/automation", response_model=ApiResponse[TPlusAutomationOverview])
def update_automation(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep,
payload: TPlusAutomationUpdate):
"""保存自动排队开关;既有活动任务继续执行。"""
return ok(automation_config.save(session, payload, task_service.TaskActor(admin, meta)))
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
"""分页查询批次元数据。"""