fix(system): code-review 15 项修复(预览基数/维度校验/脱敏穿透/并发版本号等)

- 信息员预览基数改用本人销售额(原误用折算毛利润额,金额算错且口径自相矛盾)
- 产品类别入参与维度值加存在性校验(拼错静默回退默认阈值 30% 会算错轨交工资)
- submit 校验顺序前置查库(未注册 param_code 由 500 修正为 400)
- 金额拒绝 NaN/Infinity(比较抛 InvalidOperation、计算抛溢出均穿透为 500)
- version_no 并发冲突翻译为 400(对齐 user/role service 的 IntegrityError 处理)
- 旧值留痕改取条目生效日时点的值(多未来批次下留痕链不再错位)
- 脱敏红线:phone/mobile/id_card 键名整值抹除 + 手机号正则兼容分隔符写法
- 前端:撤回按钮与导出文件名改本地日期(UTC/本地混算)、维度行内精确「改」入口、
  detailOf 补 catch、抽屉分页 size 生效、blob 错误读出真实 message
- e2e 密码移除硬编码 fallback(缺失即报错退出);按钮文案同步「改」
- 全量回归:后端 406 + 集成 104 + 前端 type-check/build + e2e 7 步 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-13 02:28:20 +08:00
parent 6219698a39
commit fbf044f240
9 changed files with 154 additions and 49 deletions

View File

@ -45,6 +45,7 @@ BUSINESS_ROLE_INFO_OFFICER = "info_officer"
# ===== 产品类别码(与 param_values.dimension_value 口径一致)===== # ===== 产品类别码(与 param_values.dimension_value 口径一致)=====
PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water" PRODUCT_CATEGORY_MUNICIPAL_WATER = "municipal_water"
PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit" PRODUCT_CATEGORY_RAIL_TRANSIT = "rail_transit"
PRODUCT_CATEGORIES = {PRODUCT_CATEGORY_MUNICIPAL_WATER, PRODUCT_CATEGORY_RAIL_TRANSIT}
# ===== 操作日志词表(operation_logs 的 module / action 取值)===== # ===== 操作日志词表(operation_logs 的 module / action 取值)=====
# 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致 # 集中定义避免各处硬编码导致日志值漂移;前端筛选下拉项与此保持一致

View File

@ -29,8 +29,8 @@ _SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
), ),
# Authorization: Bearer xxx # Authorization: Bearer xxx
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"), (re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
# 手机号:138****8888 # 手机号:138****8888(兼容带空格/横线分隔的写法,如 138 0013 8000)
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"), (re.compile(r"\b(1[3-9]\d)[-\s]?(\d{4})[-\s]?(\d{4})\b"), r"\1****\3"),
# 18 位身份证:保留前 6 位与后 4 位 # 18 位身份证:保留前 6 位与后 4 位
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"), (re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
) )

View File

@ -32,7 +32,21 @@ from app.repositories.query_utils import LogFilters, apply_log_filters
# 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 —— # 口径与 core.logging.scrub 的键名正则一致,但改为结构化处理 ——
# 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、 # 文本正则有三种逃逸形态:值以空白开头时整体匹配失败(完全不脱敏)、
# 值含空格/逗号时只抹首段、值含引号时既残留又破坏 JSON 结构。 # 值含空格/逗号时只抹首段、值含引号时既残留又破坏 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 = "***" _MASK = "***"

View File

@ -18,6 +18,9 @@ def _parse_amount(value: str, *, field_name: str, allow_negative: bool) -> str:
amount = Decimal(value.strip()) amount = Decimal(value.strip())
except InvalidOperation as exc: except InvalidOperation as exc:
raise ValueError(f"{field_name}必须是有效数字:{value}") from 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: if not allow_negative and amount < 0:
raise ValueError(f"{field_name}不得为负数:{value}") raise ValueError(f"{field_name}不得为负数:{value}")
return value return value

View File

@ -14,12 +14,14 @@ from fractions import Fraction
from io import BytesIO from io import BytesIO
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.constants import ( from app.core.constants import (
ACTION_DELETE, ACTION_DELETE,
ACTION_UPDATE, ACTION_UPDATE,
MODULE_PARAM, MODULE_PARAM,
PRODUCT_CATEGORIES,
) )
from app.core.exceptions import BadRequestError, NotFoundError from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult from app.core.response import PageResult
@ -234,10 +236,11 @@ def submit_change(
normalized: list[tuple[ParamDefinition, str, str]] = [] normalized: list[tuple[ParamDefinition, str, str]] = []
seen: set[tuple[str, str]] = set() seen: set[tuple[str, str]] = set()
for item in payload.items: for item in payload.items:
spec = get_spec(item.param_code) # 未注册编码属代码缺陷
definition = param_repository.get_definition_by_code(session, item.param_code) definition = param_repository.get_definition_by_code(session, item.param_code)
if definition is None: if definition is None:
# 用户输入先行查库给 400;未注册编码出现在库里属数据与注册表漂移
raise BadRequestError(f"参数 {item.param_code} 未在系统中定义") raise BadRequestError(f"参数 {item.param_code} 未在系统中定义")
spec = get_spec(item.param_code)
dimension = item.dimension_value dimension = item.dimension_value
if spec.dimension_type == "NONE" and dimension != "": if spec.dimension_type == "NONE" and dimension != "":
raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空") raise BadRequestError(f"参数 {item.param_code} 不带维度,维度值须为空")
@ -249,6 +252,14 @@ def submit_change(
raise BadRequestError( raise BadRequestError(
f"参数 {item.param_code} 的维度值 {dimension!r} 不是已存在的岗位" 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) value = validate_and_normalize(spec, item.value)
key = (item.param_code, dimension) key = (item.param_code, dimension)
if key in seen: if key in seen:
@ -258,6 +269,7 @@ def submit_change(
seen.add(key) seen.add(key)
normalized.append((definition, dimension, value)) normalized.append((definition, dimension, value))
try:
batch = param_repository.create_batch( batch = param_repository.create_batch(
session, session,
ParamChangeBatch( ParamChangeBatch(
@ -270,9 +282,12 @@ def submit_change(
item_count=len(normalized), item_count=len(normalized),
), ),
) )
today = date.today() # 旧值留痕取「本条目生效日时点」的值:存在多个未来生效批次时,
# 每条记录的是它实际顶替的那一版(而非提交时刻的当前值)
for definition, dimension, value in normalized: for definition, dimension, value in normalized:
old = param_repository.latest_value_before(session, definition.id, dimension, today) old = param_repository.latest_value_before(
session, definition.id, dimension, effective
)
param_repository.create_value( param_repository.create_value(
session, session,
ParamValue( ParamValue(
@ -285,6 +300,12 @@ def submit_change(
), ),
) )
session.commit() 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) logger.info("提交参数变更 | version_no=%s | items=%s", batch.version_no, batch.item_count)
return ParamChangeBatchOut( return ParamChangeBatchOut(
id=batch.id, id=batch.id,
@ -474,14 +495,16 @@ def _preview_rate(
snapshot: ParamSnapshot, snapshot: ParamSnapshot,
days: int, days: int,
take, take,
) -> tuple[Fraction, str]: ) -> tuple[Fraction, str, Decimal | None]:
"""按岗位模板分派计提比例,返回(纯比例, 人读说明)。 """按岗位模板分派计提比例,返回(纯比例, 人读说明, 绩效基数覆盖值)。
基数覆盖值非 None 表示该模板的绩效基数不是折算毛利润额(如信息员按
「本人销售额」计提),预览层直接采用并跳过折算与阈值。
聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。 聚合模板(营销经理 12.5%+5%)依赖月度团队数据,不支持单笔预览。
""" """
if payload.customer_type == "enterprise": if payload.customer_type == "enterprise":
rate = take("enterprise.rate") rate = take("enterprise.rate")
return rate, "企业户固定计提比例" return rate, "企业户固定计提比例", None
role_code = payload.business_role role_code = payload.business_role
template = "TPL_SALESPERSON_DECAY" template = "TPL_SALESPERSON_DECAY"
@ -497,7 +520,8 @@ def _preview_rate(
) )
if template == "TPL_INFO_SALES_FIXED": if template == "TPL_INFO_SALES_FIXED":
rate = take("info.rate") rate = take("info.rate")
return rate, "信息员:本人销售额 × 计提比例" # 信息员的基数是本人销售额(无折算与阈值),覆盖默认基数
return rate, "信息员:本人销售额 × 计提比例", Decimal(payload.revenue)
if template == "TPL_SALESPERSON_DECAY": if template == "TPL_SALESPERSON_DECAY":
start = take("salesperson.rate.start") start = take("salesperson.rate.start")
slope = take("salesperson.rate.slope") slope = take("salesperson.rate.slope")
@ -509,6 +533,7 @@ def _preview_rate(
return ( return (
rate, rate,
f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)", f"业务员递减:{to_decimal(start)}‰ 起,{days} 天(分界 {boundary} 天,超界取 {to_decimal(floor)}‰)",
None,
) )
raise BadRequestError(f"公式模板 {template} 暂不支持预览") raise BadRequestError(f"公式模板 {template} 暂不支持预览")
@ -541,10 +566,19 @@ def preview(session: Session, payload: ParamPreviewIn) -> ParamPreviewOut:
) )
return parse_fraction(raw) 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) 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) profit_rate = formula.profit_rate(profit, revenue)
if base_override is not None:
# 基数覆盖(信息员按本人销售额计提):不走折算与阈值
converted = base_override
else:
threshold = take("product.threshold") threshold = take("product.threshold")
converted = formula.converted_profit(revenue, profit_rate, threshold) converted = formula.converted_profit(revenue, profit_rate, threshold)
performance = formula.performance_amount(converted, rate) performance = formula.performance_amount(converted, rate)

View File

@ -62,7 +62,20 @@ class TestSerializeRequestData:
def test_手机号被脱敏(self) -> None: def test_手机号被脱敏(self) -> None:
text = _serialize_request_data({"phone": "13812345678"}) or "" text = _serialize_request_data({"phone": "13812345678"}) or ""
assert "13812345678" not in text 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: def test_超长中文按字节边界截断且无残字(self) -> None:
"""75000 字节(25000 个汉字)必然超限: """75000 字节(25000 个汉字)必然超限:

View File

@ -42,7 +42,11 @@ def main() -> int:
errors = collect_console_errors(page, console_errors) errors = collect_console_errors(page, console_errors)
try: 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") page.goto(PARAMS_URL, wait_until="networkidle")
step("1. 目录分组展示") step("1. 目录分组展示")
@ -54,7 +58,7 @@ def main() -> int:
step("2. 修改预付款比例 10 → 12(立即生效)") step("2. 修改预付款比例 10 → 12(立即生效)")
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first 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 = page.locator(".el-dialog")
dialog.locator("input").fill("12") dialog.locator("input").fill("12")
dialog.get_by_role("button", name="加入待提交").click() dialog.get_by_role("button", name="加入待提交").click()
@ -83,7 +87,7 @@ def main() -> int:
step("6. 恢复种子值 10") step("6. 恢复种子值 10")
row = page.locator(".param-table tbody tr", has_text="预付款计提比例").first 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 = page.locator(".el-dialog")
dialog.locator("input").fill("10") dialog.locator("input").fill("10")
dialog.get_by_role("button", name="加入待提交").click() dialog.get_by_role("button", name="加入待提交").click()

View File

@ -65,7 +65,19 @@ service.interceptors.response.use(
}, },
(error: AxiosError<ApiResult>) => { (error: AxiosError<ApiResult>) => {
const status = error.response?.status 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) { if (status === 401) {
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403, // 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,

View File

@ -192,13 +192,14 @@ const versionVisible = ref(false)
const batches = ref<ParamBatch[]>([]) const batches = ref<ParamBatch[]>([])
const batchesTotal = ref(0) const batchesTotal = ref(0)
const batchesPage = ref(1) const batchesPage = ref(1)
const batchesPageSize = ref(10)
const batchesLoading = ref(false) const batchesLoading = ref(false)
const detailCache = reactive<Record<number, ParamBatchDetail>>({}) const detailCache = reactive<Record<number, ParamBatchDetail>>({})
async function loadBatches(): Promise<void> { async function loadBatches(): Promise<void> {
batchesLoading.value = true batchesLoading.value = true
try { 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 batches.value = page.list
batchesTotal.value = page.total batchesTotal.value = page.total
} catch (error) { } catch (error) {
@ -219,6 +220,12 @@ function changeTypeText(changeType: number): string {
return changeType === 1 ? '立即生效' : '指定日期' 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<void> { async function handleWithdraw(batch: ParamBatch): Promise<void> {
await confirmAndRun({ await confirmAndRun({
confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`, confirmText: `确定撤回批次「${batch.version_no}」(${batch.item_count} 项,${batch.effective_date} 起生效)?撤回后这些变更不再生效。`,
@ -271,7 +278,7 @@ async function doExport(): Promise<void> {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = `param_changes_${new Date().toISOString().slice(0, 10)}.xlsx` link.download = `param_changes_${localToday()}.xlsx`
link.click() link.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
ElMessage.success('已导出') ElMessage.success('已导出')
@ -295,6 +302,7 @@ const historyItem = ref<ParamCatalogItem | null>(null)
const historyRows = ref<ParamHistory[]>([]) const historyRows = ref<ParamHistory[]>([])
const historyTotal = ref(0) const historyTotal = ref(0)
const historyPage = ref(1) const historyPage = ref(1)
const historyPageSize = ref(10)
const historyLoading = ref(false) const historyLoading = ref(false)
function openHistory(item: ParamCatalogItem): void { function openHistory(item: ParamCatalogItem): void {
@ -310,7 +318,7 @@ async function loadHistory(): Promise<void> {
try { try {
const page = await paramsApi.getParamHistory(historyItem.value.param_code, { const page = await paramsApi.getParamHistory(historyItem.value.param_code, {
pageNum: historyPage.value, pageNum: historyPage.value,
pageSize: 10, pageSize: historyPageSize.value,
}) })
historyRows.value = page.list historyRows.value = page.list
historyTotal.value = page.total historyTotal.value = page.total
@ -445,6 +453,14 @@ onMounted(() => {
> >
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }} 待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag> </el-tag>
<el-button
link
type="primary"
size="small"
@click="openEdit(item, v.dimension_value)"
>
改
</el-button>
</div> </div>
</template> </template>
<span v-else class="no-value"> <span v-else class="no-value">
@ -452,8 +468,14 @@ onMounted(() => {
</span> </span>
</td> </td>
<td> <td>
<el-button link type="primary" size="small" @click="openEdit(item, item.values[0]?.dimension_value ?? '')"> <el-button
修改 v-if="!item.values.length"
link
type="primary"
size="small"
@click="openEdit(item, '')"
>
设置
</el-button> </el-button>
<el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button> <el-button link type="primary" size="small" @click="openHistory(item)">历史</el-button>
</td> </td>
@ -614,7 +636,9 @@ onMounted(() => {
<span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span> <span class="num">{{ item.old_value_text ?? '—' }} → <b>{{ item.value_text }}</b></span>
</div> </div>
</template> </template>
<el-button v-else link size="small" @click="detailOf(row)">加载明细…</el-button> <el-button v-else link size="small" @click="detailOf(row).catch(() => {})">
加载明细…
</el-button>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
@ -628,7 +652,7 @@ onMounted(() => {
<el-table-column label="操作" width="80"> <el-table-column label="操作" width="80">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="row.effective_date > new Date().toISOString().slice(0, 10)" v-if="row.effective_date > localToday()"
link link
type="danger" type="danger"
size="small" size="small"
@ -642,9 +666,9 @@ onMounted(() => {
<AppPagination <AppPagination
:total="batchesTotal" :total="batchesTotal"
:page-num="batchesPage" :page-num="batchesPage"
:page-size="10" :page-size="batchesPageSize"
@page-change="(p: number) => { batchesPage = p; void loadBatches() }" @page-change="(p: number) => { batchesPage = p; void loadBatches() }"
@size-change="() => {}" @size-change="(s: number) => { batchesPageSize = s; batchesPage = 1; void loadBatches() }"
/> />
</div> </div>
</el-drawer> </el-drawer>
@ -667,9 +691,9 @@ onMounted(() => {
<AppPagination <AppPagination
:total="historyTotal" :total="historyTotal"
:page-num="historyPage" :page-num="historyPage"
:page-size="10" :page-size="historyPageSize"
@page-change="(p: number) => { historyPage = p; void loadHistory() }" @page-change="(p: number) => { historyPage = p; void loadHistory() }"
@size-change="() => {}" @size-change="(s: number) => { historyPageSize = s; historyPage = 1; void loadHistory() }"
/> />
</el-drawer> </el-drawer>