diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py index f21a4c0..19d225a 100644 --- a/backend/app/core/constants.py +++ b/backend/app/core/constants.py @@ -45,6 +45,7 @@ BUSINESS_ROLE_INFO_OFFICER = "info_officer" # ===== 产品类别码(与 param_values.dimension_value 口径一致)===== PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water" PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" +PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT} # ===== 操作日志词表(operation_logs 的 module / action 取值)===== # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py index c26f18b..65c1e63 100644 --- a/backend/app/core/logging.py +++ b/backend/app/core/logging.py @@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = ( ), # Authorization: Bearer xxx (re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"), - # 手机号:138****8888 - (re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"), + # 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000) + (re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"), # 18 位身份证:保留前 6 位与后 4 位 (re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"), ) diff --git a/backend/app/repositories/operation_log_repository.py b/backend/app/repositories/operation_log_repository.py index 977feb4..20b74c3 100644 --- a/backend/app/repositories/operation_log_repository.py +++ b/backend/app/repositories/operation_log_repository.py @@ -32,7 +32,21 @@ from app.repositories.query_utils import LogFilters, apply_log_filters # 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 —— # 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、 # 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。 -_SENSITIVE_KEY_PARTS = ("password", "passwd", "pwd", "secret", "token", "app_key", "access_key") +# phone/mobile/id_card 系 CLAUDE.md「日志禁输出手机号/身份证」红线的键名口径 —— +# 带分隔符的号码会穿过文本正则,必须在这里按整值抹除 +_SENSITIVE_KEY_PARTS = ( + "password", + "passwd", + "pwd", + "secret", + "token", + "app_key", + "access_key", + "phone", + "mobile", + "id_card", + "idcard", +) _MASK = "***" diff --git a/backend/app/schemas/param.py b/backend/app/schemas/param.py index 773c52e..4e963dd 100644 --- a/backend/app/schemas/param.py +++ b/backend/app/schemas/param.py @@ -18,6 +18,9 @@ def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str: amount = Decimal(value.strip()) except InvalidOperation as exc: raise ValueError(f"{field_name}必须是有效数字:{value}") from exc + if not amount.is_finite(): + # 'NaN' 的比较会抛 InvalidOperation、'Infinity' 会在后续计算抛溢出,均须在入口拦下 + raise ValueError(f"{field_name}必须为有限数字:{value}") if not allow_negative and amount < 0: raise ValueError(f"{field_name}不得为负数:{value}") return value diff --git a/backend/app/services/param_service.py b/backend/app/services/param_service.py index f8da0a3..9b68f28 100644 --- a/backend/app/services/param_service.py +++ b/backend/app/services/param_service.py @@ -14,12 +14,14 @@ from fractions import Fraction from io import BytesIO from sqlalchemy import select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core.constants import ( ACTION_DELETE, ACTION_UPDATE, MODULE_PARAM, + PRODUCT_CATEGORIES, ) from app.core.exceptions import BadRequestError, NotFoundError from app.core.response import PageResult @@ -234,10 +236,11 @@ def submit_change( normalized: list[tuple[ParamDefinition, str, str]] = [] seen: set[tuple[str, str]] = set() for item in payload.items: - spec = get_spec(item.param_code) # 未注册编码属代码缺陷 definition = param_repository.get_definition_by_code(session, item.param_code) if definition is None: + # 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移 raise BadRequestError(f"参数 {item.param_code} 未在系统中定义") + spec = get_spec(item.param_code) dimension = item.dimension_value if spec.dimension_type == "NONE" and dimension != "": raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空") @@ -249,6 +252,14 @@ def submit_change( raise BadRequestError( f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" ) + if ( + spec.dimension_type == "PRODUCT_CATEGORY" + and dimension + and dimension not in PRODUCT_CATEGORIES + ): + raise BadRequestError( + f"参数 {item.param_code} 的维度值 {dimension!r} 不是合法的产品类别" + ) value = validate_and_normalize(spec, item.value) key = (item.param_code, dimension) if key in seen: @@ -258,32 +269,42 @@ def submit_change( seen.add(key) normalized.append((definition, dimension, value)) - batch = param_repository.create_batch( - session, - ParamChangeBatch( - version_no=_next_version_no(session), - effective_date=effective, - change_type=payload.change_type, - remark=payload.remark, - operator_id=operator.id, - operator_name=operator.username, - item_count=len(normalized), - ), - ) - today = date.today() - for definition, dimension, value in normalized: - old = param_repository.latest_value_before(session, definition.id, dimension, today) - param_repository.create_value( + try: + batch = param_repository.create_batch( session, - ParamValue( - param_id=definition.id, - dimension_value=dimension, + ParamChangeBatch( + version_no=_next_version_no(session), effective_date=effective, - value_text=value, - old_value_text=old.value_text if old else None, - batch_id=batch.id, + change_type=payload.change_type, + remark=payload.remark, + operator_id=operator.id, + operator_name=operator.username, + item_count=len(normalized), ), ) + # 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时, + # 每条记录的是它实际顶替的那一版(而非提交时刻的当前值) + for definition, dimension, value in normalized: + old = param_repository.latest_value_before( + session, definition.id, dimension, effective + ) + param_repository.create_value( + session, + ParamValue( + param_id=definition.id, + dimension_value=dimension, + effective_date=effective, + value_text=value, + old_value_text=old.value_text if old else None, + batch_id=batch.id, + ), + ) + session.commit() + except IntegrityError as exc: + # version_no 唯一键的并发冲突:当日序号是无锁查询后 +1,两名管理员 + # 并发提交会生成相同版本号 —— 回滚并翻译为可重试的 400 + session.rollback() + raise BadRequestError("提交冲突(版本号生成重复),请重试") from exc session.commit() logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count) return ParamChangeBatchOut( @@ -474,14 +495,16 @@ def _preview_rate( snapshot: ParamSnapshot, days: int, take, -) -> tuple[Fraction, str]: - """按岗位模板分派计提比例,返回(纯比例, 人读说明)。 +) -> tuple[Fraction, str, Decimal | None]: + """按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。 + 基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按 + 「本人销售额」计提),预览层直接采用并跳过折算与阈值。 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 """ if payload.customer_type == "enterprise": rate = take("enterprise.rate") - return rate, "企业户固定计提比例" + return rate, "企业户固定计提比例", None role_code = payload.business_role template = "TPL_SALESPERSON_DECAY" @@ -497,7 +520,8 @@ def _preview_rate( ) if template == "TPL_INFO_SALES_FIXED": rate = take("info.rate") - return rate, "信息员:本人销售额 × 计提比例" + # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数 + return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue) if template == "TPL_SALESPERSON_DECAY": start = take("salesperson.rate.start") slope = take("salesperson.rate.slope") @@ -509,6 +533,7 @@ def _preview_rate( return ( rate, f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)", + None, ) raise BadRequestError(f"公式模板 {template} 暂不支持预览") @@ -541,12 +566,21 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut: ) return parse_fraction(raw) + if payload.product_category and payload.product_category not in PRODUCT_CATEGORIES: + raise BadRequestError( + f"产品类别 {payload.product_category!r} 不存在,可选:municipal_water / rail_transit" + ) + days = formula.payment_days(payload.ship_date, payload.receipt_date) - rate, rate_text = _preview_rate(session, payload, snapshot, days, take) + rate, rate_text, base_override = _preview_rate(session, payload, snapshot, days, take) profit_rate = formula.profit_rate(profit, revenue) - threshold = take("product.threshold") - converted = formula.converted_profit(revenue, profit_rate, threshold) + if base_override is not None: + # 基数覆盖(信息员按本人销售额计提):不走折算与阈值 + converted = base_override + else: + threshold = take("product.threshold") + converted = formula.converted_profit(revenue, profit_rate, threshold) performance = formula.performance_amount(converted, rate) barter_applied = payload.barter diff --git a/backend/tests/unit/test_audit_serialization.py b/backend/tests/unit/test_audit_serialization.py index 9f10873..6805dbb 100644 --- a/backend/tests/unit/test_audit_serialization.py +++ b/backend/tests/unit/test_audit_serialization.py @@ -62,7 +62,20 @@ class TestSerializeRequestData: def test_手机号被脱敏(self) -> None: text = _serialize_request_data({"phone": "13812345678"}) or "" assert "13812345678" not in text - assert "138****5678" in text + # phone 键已加入结构化抹除清单(整值 ***)—— 带空格/横线分隔的号码 + # (如 138 0013 8000)会穿过分值文本正则,键名整值抹除是更强的防线 + assert '"phone": "***"' in text + + def test_带分隔符的手机号被脱敏(self) -> None: + """修复回归:'138 0013 8000' 类分隔符写法曾同时穿过分值正则与键名清单。""" + for payload in ( + {"phone": "138 0013 8000"}, + {"phone": "138-0013-8000"}, + {"mobile": "13800138000"}, + ): + text = _serialize_request_data(payload) or "" + assert "13800138000" not in text.replace(" ", "").replace("-", "") + assert "***" in text def test_超长中文按字节边界截断且无残字(self) -> None: """75000 字节(25000 个汉字)必然超限: diff --git a/frontend/e2e/param_management.py b/frontend/e2e/param_management.py index d63be48..2da1ee5 100644 --- a/frontend/e2e/param_management.py +++ b/frontend/e2e/param_management.py @@ -42,7 +42,11 @@ def main() -> int: errors = collect_console_errors(page, console_errors) try: - login(page, "admin", __import__("os").environ.get("E2E_ADMIN_PASSWORD", "E2ePwd@2026")) + admin_password = __import__("os").environ.get("E2E_ADMIN_PASSWORD") + if not admin_password: + print("✗ 缺少环境变量 E2E_ADMIN_PASSWORD(管理员密码不入库)") + return 1 + login(page, "admin", admin_password) page.goto(PARAMS_URL, wait_until="networkidle") step("1. 目录分组展示") @@ -54,7 +58,7 @@ def main() -> int: step("2. 修改预付款比例 10 → 12(立即生效)") row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first - row.get_by_role("button", name="修改").click() + row.get_by_role("button", name="改", exact=True).click() dialog = page.locator(".el-dialog") dialog.locator("input").fill("12") dialog.get_by_role("button", name="加入待提交").click() @@ -83,7 +87,7 @@ def main() -> int: step("6. 恢复种子值 10") row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first - row.get_by_role("button", name="修改").click() + row.get_by_role("button", name="改", exact=True).click() dialog = page.locator(".el-dialog") dialog.locator("input").fill("10") dialog.get_by_role("button", name="加入待提交").click() diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts index b4fb33e..a973eea 100644 --- a/frontend/src/api/request.ts +++ b/frontend/src/api/request.ts @@ -65,7 +65,19 @@ service.interceptors.response.use( }, (error: AxiosError) => { const status = error.response?.status - const message = error.response?.data?.message ?? '网络异常,请稍后重试' + let message = error.response?.data?.message ?? '网络异常,请稍后重试' + // blob 请求失败时 data 是 Blob(如导出接口返回的 JSON 信封错误),需读出真实 message + if (error.response?.data instanceof Blob) { + message = '导出失败,请稍后重试' + void error.response.data.text().then((text) => { + try { + const body = JSON.parse(text) as { message?: string } + if (body.message) ElMessage.error(body.message) + } catch { + /* 非 JSON 响应,保持兜底提示 */ + } + }) + } if (status === 401) { // 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403, diff --git a/frontend/src/views/system/params/index.vue b/frontend/src/views/system/params/index.vue index 27dd814..36ddfa5 100644 --- a/frontend/src/views/system/params/index.vue +++ b/frontend/src/views/system/params/index.vue @@ -192,13 +192,14 @@ const versionVisible = ref(false) const batches = ref([]) const batchesTotal = ref(0) const batchesPage = ref(1) +const batchesPageSize = ref(10) const batchesLoading = ref(false) const detailCache = reactive>({}) async function loadBatches(): Promise { batchesLoading.value = true try { - const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: 10 }) + const page = await paramsApi.listBatches({ pageNum: batchesPage.value, pageSize: batchesPageSize.value }) batches.value = page.list batchesTotal.value = page.total } catch (error) { @@ -219,6 +220,12 @@ function changeTypeText(changeType: number): string { return changeType === 1 ? '立即生效' : '指定日期' } +/** 本地日期(YYYY-MM-DD):toISOString 是 UTC,北京时间 0–8 点会差一天 */ +function localToday(): string { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + async function handleWithdraw(batch: ParamBatch): Promise { await confirmAndRun({ confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`, @@ -271,7 +278,7 @@ async function doExport(): Promise { const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url - link.download = `param_changes_${new Date().toISOString().slice(0, 10)}.xlsx` + link.download = `param_changes_${localToday()}.xlsx` link.click() URL.revokeObjectURL(url) ElMessage.success('已导出') @@ -295,6 +302,7 @@ const historyItem = ref(null) const historyRows = ref([]) const historyTotal = ref(0) const historyPage = ref(1) +const historyPageSize = ref(10) const historyLoading = ref(false) function openHistory(item: ParamCatalogItem): void { @@ -310,7 +318,7 @@ async function loadHistory(): Promise { try { const page = await paramsApi.getParamHistory(historyItem.value.param_code, { pageNum: historyPage.value, - pageSize: 10, + pageSize: historyPageSize.value, }) historyRows.value = page.list historyTotal.value = page.total @@ -445,6 +453,14 @@ onMounted(() => { > 待改为 {{ pendingOf(item, v.dimension_value)!.new_value }} + + 改 + @@ -452,8 +468,14 @@ onMounted(() => { - - 修改 + + 设置 历史 @@ -614,7 +636,9 @@ onMounted(() => { {{ item.old_value_text ?? '—' }} → {{ item.value_text }} - 加载明细… + + 加载明细… + @@ -628,7 +652,7 @@ onMounted(() => {