Compare commits

...

7 Commits

Author SHA1 Message Date
liuwenxin
1b074576fe fix(后端): 区分 T+ 连接文件的缺失与字段错误
原先 OSError、ValueError、ValidationError 统一归并为「不可用
或字段不正确」,文件不存在和字段写错无法区分,排障困难。
拆分为两条消息分别指示故障类别,仍不回显文件内容与路径。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:58:47 +08:00
liuwenxin
013d6e7a74 docs(测试): 标注 T+ 前端 e2e 待补
按模块测试约定,T+ 页面验收节点应统一编写 Playwright 用例
并记录运行结果;当前仅有 type-check 验证,显式标注避免遗忘。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:58:27 +08:00
liuwenxin
0ca1734523 refactor(前端): 清理 T+ 页面展示细节
详情对话框:异常明细补充后端已支持的范围筛选,执行时间行
在批次未开始时隐藏(原先显示「— → —」);核验结果提示与
页首警告统一 show-icon;删除已声明未消费的 receipt_line_kinds
类型字段(方向信息已在 receipt_kinds 名称内)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:58:08 +08:00
liuwenxin
b0c599e513 fix(脚本): 收紧 T+ 运维脚本校验与恢复行为
login 脚本原先在结尾无条件 SET READ_ONLY:库名误指向其他
可写库时会把它锁成只读。改为开头校验库存在(不存在即中止)
并记录初始 Updateability,结尾仅当初始为 READ_ONLY 时恢复。
inventory 脚本的库名校验统一为 restore 脚本的字符白名单。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:57:30 +08:00
liuwenxin
2fda2d118b refactor(脚本): 复用应用连接校验与 URL 转义
verify_staging 原先 setattr 改写全局 Settings 单例,绕过
_ConnectionFile 严格校验与 sa 账号拒绝;profile_source 手工
拼接 ODBC 连接串且仅转义密码,database/user 含分号时可注入
连接属性。两者统一改为 model_copy 副本 + resolve_tplus_settings
完整校验 + SQLAlchemy URL 自建引擎,与 check_compatibility
同一路径,顺带消除 pyodbc connect 上下文的提交语义隐患。

真实源库连接的功能验证待本机 T+ 环境执行。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:57:07 +08:00
liuwenxin
37eb51513f fix(前端): 兼容非安全上下文的任务请求键生成
crypto.randomUUID 仅在 HTTPS 或 localhost 下存在,客户内网
以 http://<内网IP> 访问时该 API 为 undefined,而新建任务
对话框在页面挂载即调用,会直接 TypeError 导致整页白屏。

新增 newRequestKey 工具:优先 randomUUID,不可用时降级为
getRandomValues 拼 v4 格式(非安全上下文仍可用)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:56:41 +08:00
liuwenxin
ee801652e3 fix(运维): 限制 T+ 开发库端口仅本机访问
SQL Server 容器还原的是客户真实账套且带 sa 账号,1433 端口
原先绑定全部网卡会暴露到局域网。改为 127.0.0.1 回环地址,
与 MySQL 开发库的绑定先例保持一致,宿主机访问不受影响。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-30 09:56:13 +08:00
12 changed files with 123 additions and 40 deletions

View File

@ -43,8 +43,11 @@ def resolve_tplus_settings(settings: Settings) -> Settings:
config = _ConnectionFile.model_validate(
json.loads(path.read_text(encoding="utf-8-sig"))
)
except (OSError, ValueError, ValidationError):
raise RuntimeError("T+ 只读连接文件不可用或字段不正确") from None
except OSError:
# 消息只区分故障类别,不回显路径内容;路径本身可在应用配置中核对。
raise RuntimeError("T+ 只读连接文件不存在或不可读") from None
except (ValueError, ValidationError):
raise RuntimeError("T+ 只读连接文件字段不正确") from None
values = {
"t_plus_" + key: getattr(config, key) for key in ("host", "port", "database", "user")
}

View File

@ -17,7 +17,7 @@ services:
MSSQL_SA_PASSWORD: ${TPLUS_SA_PASSWORD:?请在 D:/work/app/tplus-sqlserver/.env 设置 TPLUS_SA_PASSWORD}
TZ: Asia/Shanghai
ports:
- "1433:1433"
- "127.0.0.1:1433:1433"
volumes:
- type: bind
source: ${TPLUS_ROOT:-D:/work/app/tplus-sqlserver}/data

View File

@ -7,7 +7,7 @@
- 登录页与图形验证码交互
- 会话状态管理与路由守卫
- 管理员 T+ 数据同步页:`/system/tplus`,支持读取排队、关联检查、批次/异常详情和失败重试;只读配置未就绪时禁用创建。
- T+ 页面位于 `src/views/system/tplus/`,合同在 `src/api/tplus.ts`;创建或重试失败后保留请求键,范围改变或提交成功后才换键。任务由独立读取服务执行,页面刷新查看状态,运行命令见 T+ 本地恢复手册。
- T+ 页面位于 `src/views/system/tplus/`,合同在 `src/api/tplus.ts`;创建或重试失败后保留请求键,范围改变或提交成功后才换键。任务由独立读取服务执行,页面刷新查看状态,运行命令见 T+ 本地恢复手册。**e2e 待补(2026-10-30)**:按模块测试约定应在本模块验收节点统一编写并记录,当前仅有 type-check 验证,尚未落 Playwright 用例。
- Vite 开发代理可通过 `DEV_API_TARGET` 指定后端,默认本地 8000;浏览器请求继续使用 `/api` 相对地址。
- 岗位角色管理页(模板绑定)、计提参数配置页(分组目录/批次提交/版本对比/导出/计算预览)
- 首页 Bento 仪表盘(M1 空态设计;业务上线后接入真实统计,图表用 ECharts)

View File

@ -16,7 +16,6 @@ export interface AuditReport {
order_coverage: Record<string, number> | null
state_enums: { id: number; code: string; name: string; enum_name: string }[]
receipt_kinds: { voucher_type_name: string; row_count: number }[]
receipt_line_kinds: { voucher_type_name: string | null; receive_flag: number; row_count: number }[]
pending_confirmations: { key: string; title: string; reason: string }[]
}

View File

@ -0,0 +1,11 @@
// crypto.randomUUID 仅在安全上下文(HTTPS 或 localhost)存在;客户内网常以
// http://<内网IP> 访问,此时该 API 为 undefined。降级用 getRandomValues 拼
// v4 格式(getRandomValues 在非安全上下文仍然可用)。
export function newRequestKey(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID()
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 0x0f) | 0x40 // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80 // variant 10x
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0'))
return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex.slice(6, 8).join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10, 16).join('')}`
}

View File

@ -17,7 +17,7 @@ const rows = computed(() => Object.entries(props.report.sales_links ?? {}).map((
<template>
<section class="audit-result" aria-label="关联核验结果">
<p class="audit-caption">核验于 {{ formatDateTime(report.captured_at) }} · 源引擎 {{ report.source_version }}</p>
<el-alert type="warning" :closable="false" title="SQL Server 2008 R2 同版本验收待完成;关联存在不代表业务口径已确认。" />
<el-alert type="warning" :closable="false" show-icon title="SQL Server 2008 R2 同版本验收待完成;关联存在不代表业务口径已确认。" />
<p v-if="report.relationship_status !== 'OBSERVED'" role="alert">单据类型未能唯一确认,关联统计未执行。</p>
<el-table v-if="rows.length" :data="rows" size="small" class="audit-table">
<el-table-column prop="label" label="核验项目" min-width="190" />

View File

@ -12,9 +12,13 @@ const number = defineModel<string | null>({ required: true })
const detail = ref<api.TPlusDetail | null>(null)
const loading = ref(false)
const failed = ref(false)
const errorEntityType = ref<api.EntityType>()
let sequence = 0
const { records, total, pageNum, pageSize, loading: errorsLoading, search, onPageChange, onSizeChange } =
usePagedList<api.TPlusError, api.ErrorQuery>((query) => api.listErrors(number.value!, query), () => ({}))
usePagedList<api.TPlusError, api.ErrorQuery>(
(query) => api.listErrors(number.value!, query),
() => ({ entity_type: errorEntityType.value }),
)
async function load(): Promise<void> {
const current = ++sequence
@ -46,7 +50,7 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
<el-descriptions-item label="状态">{{ statuses[detail.status] }}</el-descriptions-item>
<el-descriptions-item label="范围">{{ scopeLabel(detail) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(detail.create_time) }}</el-descriptions-item>
<el-descriptions-item label="执行时间">{{ formatDateTime(detail.started_at) }} → {{ formatDateTime(detail.finished_at) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.started_at" label="执行时间">{{ formatDateTime(detail.started_at) }} → {{ formatDateTime(detail.finished_at) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.parent_batch_no" label="原批次">{{ detail.parent_batch_no }}</el-descriptions-item>
<el-descriptions-item label="数量">源记录 {{ detail.source_record_count.toLocaleString() }},新增 {{ detail.staged_record_count.toLocaleString() }},重复跳过 {{ detail.skipped_record_count.toLocaleString() }},异常 {{ detail.failed_record_count.toLocaleString() }}</el-descriptions-item>
</el-descriptions>
@ -55,6 +59,9 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
<AuditResult v-if="detail.audit_result" :report="detail.audit_result" />
<section v-if="detail.failed_record_count" class="errors-section" aria-label="异常明细">
<h3>异常明细</h3>
<el-select v-model="errorEntityType" clearable placeholder="全部范围" aria-label="异常范围" @change="search">
<el-option v-for="item in entities" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-table v-loading="errorsLoading" :data="records" empty-text="暂无异常记录">
<el-table-column label="范围" width="100"><template #default="{ row }">{{ entities.find((item) => item.value === row.entity_type)?.label ?? '任务' }}</template></el-table-column>
<el-table-column prop="source_id" label="源 ID" width="90" />
@ -73,6 +80,7 @@ watch(number, (value) => { if (value) void load(); else sequence++ })
.detail-body { min-height: 120px; }
.batch-number { overflow-wrap: anywhere; }
.errors-section { margin-top: 24px; }
.errors-section .el-select { width: 160px; margin-bottom: 12px; }
h3 { font-size: 14px; }
.pager-scroll { overflow-x: auto; }
</style>

View File

@ -3,6 +3,7 @@ import { ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import * as api from '@/api/tplus'
import { handledError } from '@/utils/error'
import { newRequestKey } from '@/utils/uuid'
import { entities } from './labels'
const visible = defineModel<boolean>({ required: true })
@ -11,9 +12,9 @@ const emit = defineEmits<{ created: [batch: api.TPlusDetail] }>()
const taskType = ref<api.TaskType>('READ')
const selected = ref<api.EntityType[]>(['personnel'])
const pending = ref(false)
let requestKey = crypto.randomUUID()
let requestKey = newRequestKey()
// 请求失败后保留键,只有意图改变或成功后才换键。
watch([taskType, selected], () => { requestKey = crypto.randomUUID() }, { deep: true, flush: 'sync' })
watch([taskType, selected], () => { requestKey = newRequestKey() }, { deep: true, flush: 'sync' })
async function submit(): Promise<void> {
if (pending.value || !props.configured) return
@ -21,7 +22,7 @@ async function submit(): Promise<void> {
try {
const batch = await api.createTask({ task_type: taskType.value,
entity_types: taskType.value === 'AUDIT' ? [] : [...selected.value], request_key: requestKey })
requestKey = crypto.randomUUID()
requestKey = newRequestKey()
visible.value = false
ElMessage.success('任务已排队,处理完成后可刷新查看')
emit('created', batch)

View File

@ -30,7 +30,14 @@ $safeDatabaseLiteral = $DatabaseName.Replace("'", "''")
$safeLoginIdentifier = $LoginName.Replace("]", "]]" )
$safeReaderPassword = $readerPasswordText.Replace("'", "''")
$sql = @"
IF DATABASEPROPERTYEX(N'$safeDatabaseLiteral', 'Updateability') = N'READ_ONLY'
IF DB_ID(N'$safeDatabaseLiteral') IS NULL
BEGIN
;THROW 51000, N'目标数据库不存在,已停止配置,未修改任何库。', 1;
END;
DECLARE @initialReadonly bit =
CASE WHEN CAST(DATABASEPROPERTYEX(N'$safeDatabaseLiteral', 'Updateability') AS nvarchar(30)) = N'READ_ONLY'
THEN 1 ELSE 0 END;
IF @initialReadonly = 1
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_WRITE;
IF NOT EXISTS (SELECT 1 FROM sys.server_principals WHERE name = N'$safeLoginIdentifier')
BEGIN
@ -49,8 +56,9 @@ IF NOT EXISTS (
)
EXEC sp_addrolemember N'db_datareader', N'$safeLoginIdentifier';
DENY INSERT, UPDATE, DELETE TO [$safeLoginIdentifier];
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
IF @initialReadonly = 1
ALTER DATABASE [$safeDatabaseIdentifier] SET READ_ONLY;
"@
Invoke-TPlusContainerSql -ContainerName $ContainerName -Sql $sql -SaPassword $SaPassword
Write-Host "只读账号已配置:$LoginName(未输出密码)"
Write-Host "只读账号已配置:$LoginName(未输出密码)。仅当目标库初始为 READ_ONLY 时才恢复只读;初始为可写的库保持可写。"

View File

@ -13,6 +13,10 @@ $ErrorActionPreference = "Stop"
. (Join-Path $PSScriptRoot "container_sql.ps1")
if ($DatabaseName -notmatch '^[A-Za-z_][A-Za-z0-9_]{0,127}$') {
throw "核验数据库名只能使用字母、数字和下划线。"
}
New-Item -ItemType Directory -Path $ReportRoot -Force | Out-Null
$reportPath = Join-Path $ReportRoot ("Tplus-核验-{0:yyyyMMdd-HHmmss}.md" -f (Get-Date))
$safeDatabaseIdentifier = $DatabaseName.Replace("]", "]]" )

View File

@ -2,9 +2,15 @@
import argparse
import json
import sys
from pathlib import Path
import pyodbc
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
from sqlalchemy import Engine, create_engine
from app.core.config import Settings, get_settings
from app.core.tplus_db import resolve_tplus_settings
QUERIES = {
"personnel_dates": "SELECT COUNT_BIG(*) AS total, SUM(CASE WHEN updated IS NULL THEN 1 ELSE 0 END) AS null_updated FROM AA_Person",
@ -20,25 +26,43 @@ QUERIES = {
}
def build_source_engine(resolved: Settings) -> Engine:
"""复用应用的 URL 转义和 TLS 配置自建引擎,不修改全局设置。"""
return create_engine(
resolved.t_plus_database_url,
connect_args={"timeout": resolved.t_plus_connect_timeout},
hide_parameters=True,
)
def main() -> int:
"""执行固定白名单统计,并保存无凭据、无明细的 JSON。"""
parser = argparse.ArgumentParser(description="T+ 只读数据质量统计")
parser.add_argument("--connection-file", required=True, type=Path)
parser.add_argument("--report", required=True, type=Path)
args = parser.parse_args()
c = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
cs = ("DRIVER={" + c["driver"] + "};SERVER=" + c["host"] + "," + str(c["port"])
+ ";DATABASE=" + c["database"] + ";UID=" + c["user"]
+ ";PWD={" + c["password"].replace("}", "}}") + "};")
resolved = resolve_tplus_settings(
get_settings().model_copy(
update={"t_plus_enabled": True, "t_plus_connection_file": str(args.connection_file)}
)
)
engine = build_source_engine(resolved)
result = {}
with pyodbc.connect(cs, timeout=10) as connection:
connection.timeout = 120
cursor = connection.cursor()
for name, sql in QUERIES.items():
cursor.execute(sql)
names = [column[0] for column in cursor.description]
result[name] = [dict(zip(names, row, strict=True)) for row in cursor.fetchall()]
print(name, json.dumps(result[name]), flush=True)
try:
connection = engine.raw_connection()
try:
connection.driver_connection.timeout = resolved.t_plus_query_timeout
cursor = connection.cursor()
for name, sql in QUERIES.items():
cursor.execute(sql)
names = [column[0] for column in cursor.description]
result[name] = [dict(zip(names, row, strict=True)) for row in cursor.fetchall()]
print(name, json.dumps(result[name]), flush=True)
cursor.close()
finally:
connection.close()
finally:
engine.dispose()
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(result, indent=2), encoding="utf-8")
return 0

View File

@ -7,16 +7,38 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "backend"))
from sqlalchemy import func, select
from sqlalchemy import Engine, create_engine, event, func, select
from sqlalchemy.orm import sessionmaker
from app.core.config import get_settings
from app.core.config import Settings, get_settings
from app.core.db import dispose_engine, get_session_factory
from app.core.tplus_db import dispose_tplus_engine, get_tplus_session_factory
from app.core.tplus_db import resolve_tplus_settings
from app.models import TPlusSyncRecord
from app.services.tplus.mapping import DETAIL_MAPPINGS, TPLUS_MAPPINGS
from app.services.tplus.sync_service import TPlusSyncService
def build_source_engine(resolved: Settings) -> Engine:
"""复用应用的 URL 转义和 TLS 配置自建引擎,不修改全局设置。
不能走 get_tplus_session_factory():它读取全局单例配置,看不到本脚本
显式指定的连接文件;因此这里自建引擎,用完由调用方 dispose。
"""
engine = create_engine(
resolved.t_plus_database_url,
pool_pre_ping=True,
pool_recycle=1800,
connect_args={"timeout": resolved.t_plus_connect_timeout},
hide_parameters=True,
)
def configure_timeout(connection, _record):
connection.timeout = resolved.t_plus_query_timeout
event.listen(engine, "connect", configure_timeout)
return engine
def main() -> int:
"""执行一次或重复快照,并将计数结果保存到指定报告。"""
parser = argparse.ArgumentParser(description="T+ 本地只读快照与幂等对账")
@ -24,17 +46,20 @@ def main() -> int:
parser.add_argument("--report", type=Path, required=True)
parser.add_argument("--repeat", action="store_true")
args = parser.parse_args()
config = json.loads(args.connection_file.read_text(encoding="utf-8-sig"))
settings = get_settings()
settings.t_plus_enabled = True
for key in ("host", "port", "database", "user", "password"):
setattr(settings, "t_plus_" + key, config[key])
settings.t_plus_odbc_driver = config["driver"]
source_factory, target_factory = get_tplus_session_factory(), get_session_factory()
resolved = resolve_tplus_settings(
get_settings().model_copy(
update={"t_plus_enabled": True, "t_plus_connection_file": str(args.connection_file)}
)
)
engine = build_source_engine(resolved)
source_factory, target_factory = (
sessionmaker(engine, expire_on_commit=False),
get_session_factory(),
)
reports, failed = [], False
try:
with source_factory() as source, target_factory() as target:
for run in range(1, 3 if args.repeat else 2):
for run in range(2 if args.repeat else 1):
for mapping in (*TPLUS_MAPPINGS, *DETAIL_MAPPINGS):
batch = TPlusSyncService(source, target).run(entity_types=[mapping.entity_type])
count = target.scalar(
@ -55,14 +80,14 @@ def main() -> int:
print(json.dumps(row), flush=True)
reports.append(row)
failed |= batch.status != "SUCCEEDED" or (
run == 2 and batch.staged_record_count != 0
run == 1 and batch.staged_record_count != 0
)
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(
json.dumps(reports, ensure_ascii=False, indent=2), encoding="utf-8"
)
finally:
dispose_tplus_engine()
engine.dispose()
dispose_engine()
return int(failed)