feat(后端): 添加授权体系、系统监控与试用期计提
按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
这个提交包含在:
1 父节点
02fb06e0af
当前提交
1461a8d113
149 个文件已更改
+8736
-368
没有与搜索条件相匹配的文件
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")])
|
||||
@@ -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 "已设为离职")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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="模板已删除")
|
||||
@@ -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)))
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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()]):
|
||||
"""分页查询批次元数据。"""
|
||||
|
||||
在新工单中引用
屏蔽一个用户