Files
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

571 lines
19 KiB
Go

package export
import (
"database/sql"
"fmt"
"io"
"strconv"
"strings"
"github.com/xuri/excelize/v2"
)
// 基础数据包的四个工作表名。导入按这几个中文名认表:缺哪张就整张跳过,
// 一张都没有才报错——「没写的表」代表不改,绝不代表删除。
const (
BaseDataSheetTeams = "单位"
BaseDataSheetSports = "大项"
BaseDataSheetEvents = "小项"
BaseDataSheetCategories = "组别"
)
// BaseDataHeader 是一列表头与字段的对应关系。
// Header 是导出写进单元格的中文表头,Field 是导入按它取值的规范字段名。
// 导出与导入共用这一份定义:表头文案只有一处,往返(导出→原样导入)天然对得上。
type BaseDataHeader struct {
Header string
Field string
Required bool
}
// BaseDataSheetSpec 是一张基础数据表的导入契约:表名 + 表头集合。
type BaseDataSheetSpec struct {
Sheet string
Headers []BaseDataHeader
}
// baseDataColumn 在表头之外带上版式(列宽 / 对齐),仅供导出用。
type baseDataColumn struct {
BaseDataHeader
width float64
kind cellKind
}
type baseDataSheet struct {
sheet string
columns []baseDataColumn
}
// baseDataSheets 是基础数据包的全部内容定义,顺序即导入顺序:
// 单位 → 大项 → 小项 → 组别,后者依赖前者的编码(组别依赖小项,小项依赖大项)。
var baseDataSheets = []baseDataSheet{
{
sheet: BaseDataSheetTeams,
columns: []baseDataColumn{
{BaseDataHeader{"单位编码", "code", true}, 14, kindCenter},
{BaseDataHeader{"单位名称", "name", true}, 24, kindText},
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
{BaseDataHeader{"颜色", "color", false}, 12, kindCenter},
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
},
},
{
sheet: BaseDataSheetSports,
columns: []baseDataColumn{
{BaseDataHeader{"大项编码", "code", true}, 14, kindCenter},
{BaseDataHeader{"大项名称", "name", true}, 24, kindText},
{BaseDataHeader{"简称", "shortName", false}, 12, kindText},
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
{BaseDataHeader{"启用", "active", false}, 8, kindCenter},
},
},
{
sheet: BaseDataSheetEvents,
columns: []baseDataColumn{
{BaseDataHeader{"大项编码", "sportCode", true}, 14, kindCenter},
{BaseDataHeader{"小项编码", "code", true}, 14, kindCenter},
{BaseDataHeader{"小项名称", "name", true}, 22, kindText},
{BaseDataHeader{"类型", "eventType", false}, 14, kindCenter},
{BaseDataHeader{"结果模式", "resultMode", false}, 10, kindCenter},
{BaseDataHeader{"结果类型", "resultType", false}, 10, kindCenter},
{BaseDataHeader{"比较", "compare", false}, 8, kindCenter},
{BaseDataHeader{"计分", "scored", false}, 8, kindCenter},
{BaseDataHeader{"计破纪录", "recordEligible", false}, 10, kindCenter},
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
{BaseDataHeader{"状态", "status", false}, 10, kindCenter},
{BaseDataHeader{"平均口径", "avgMode", false}, 10, kindCenter},
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
{BaseDataHeader{"场地", "venue", false}, 16, kindText},
{BaseDataHeader{"备注", "remark", false}, 20, kindText},
},
},
{
sheet: BaseDataSheetCategories,
columns: []baseDataColumn{
// 组别必须带所属小项编码,否则导回来时对不上是哪个体项目的组别。
{BaseDataHeader{"小项编码", "eventCode", true}, 14, kindCenter},
{BaseDataHeader{"组别名", "name", true}, 16, kindText},
{BaseDataHeader{"权重", "multiplier", false}, 8, kindNumber},
{BaseDataHeader{"性别", "gender", false}, 8, kindCenter},
{BaseDataHeader{"年龄下限", "ageMin", false}, 10, kindInt},
{BaseDataHeader{"年龄上限", "ageMax", false}, 10, kindInt},
{BaseDataHeader{"排序", "sortOrder", false}, 8, kindInt},
},
},
}
// BaseDataSpecs 返回基础数据包的导入契约(表名与表头),顺序即导入顺序。
// 调用方(httpapi)拿它做表头识别与行级校验,不再自己写一份中文表头。
func BaseDataSpecs() []BaseDataSheetSpec {
out := make([]BaseDataSheetSpec, 0, len(baseDataSheets))
for _, s := range baseDataSheets {
spec := BaseDataSheetSpec{Sheet: s.sheet, Headers: make([]BaseDataHeader, 0, len(s.columns))}
for _, c := range s.columns {
spec.Headers = append(spec.Headers, c.BaseDataHeader)
}
out = append(out, spec)
}
return out
}
// BaseDataRecord 是一行待写出的基础数据:字段名 → 单元格值。
type BaseDataRecord map[string]any
// BaseDataWorkbook 是基础数据包的内容,一张表一份记录列表。
type BaseDataWorkbook struct {
Meta Meta
Teams []BaseDataRecord
Sports []BaseDataRecord
Events []BaseDataRecord
Categories []BaseDataRecord
}
// records 按工作表名取记录,顺序与 baseDataSheets 一致。
func (wb BaseDataWorkbook) records(sheet string) []BaseDataRecord {
switch sheet {
case BaseDataSheetTeams:
return wb.Teams
case BaseDataSheetSports:
return wb.Sports
case BaseDataSheetEvents:
return wb.Events
case BaseDataSheetCategories:
return wb.Categories
default:
return nil
}
}
// WriteBaseDataXLSX 把基础数据包写成 xlsx 流,供 HTTP 直接响应下载。
func WriteBaseDataXLSX(w io.Writer, wb BaseDataWorkbook) error {
f, err := baseDataFile(wb)
if err != nil {
return err
}
return f.Write(w)
}
func baseDataFile(wb BaseDataWorkbook) (*excelize.File, error) {
return buildWorkbook(BaseDataSheetTeams, func(f *excelize.File, st *styleSet) error {
meta := wb.Meta.normalized()
namer := &sheetNamer{}
for _, s := range baseDataSheets {
name := namer.add(s.sheet)
if err := addSheet(f, name); err != nil {
return err
}
cols := make([]column, 0, len(s.columns))
for _, c := range s.columns {
cols = append(cols, column{c.Header, c.width, c.kind})
}
rows := baseDataRows(s, wb.records(s.sheet))
if err := writeSheet(f, st, name, meta, sheetTitle(meta, s.sheet), cols, rows); err != nil {
return err
}
}
return nil
})
}
// baseDataRows 按列定义把记录摊平成行;记录里缺的字段写空,不写 "0" / "false" 这类噪声。
func baseDataRows(s baseDataSheet, records []BaseDataRecord) [][]any {
out := make([][]any, 0, len(records))
for _, rec := range records {
row := make([]any, 0, len(s.columns))
for _, c := range s.columns {
v, ok := rec[c.Field]
if !ok || v == nil {
v = ""
}
row = append(row, v)
}
out = append(out, row)
}
return out
}
// boolCell 把布尔值写成「是 / 否」:中文表头配中文取值,现场改表时不用查英文枚举。
func boolCell(v bool) string {
if v {
return "是"
}
return "否"
}
// LoadBaseDataWorkbook 读取全部基础数据(单位 / 大项 / 小项 / 组别)。
//
// 单位排除「彻底删除」(deleted_at 非空)的行:它们对界面不可见,也不该被重新印进导出件;
// 停用(active=0)的单位照常导出,启用列写「否」,导入回来才不会把它误改回启用。
func LoadBaseDataWorkbook(db *sql.DB, opts Options) (BaseDataWorkbook, error) {
if db == nil {
return BaseDataWorkbook{}, ErrNoDatabase
}
meta, _, err := resolveMeta(db, opts.Meta)
if err != nil {
return BaseDataWorkbook{}, err
}
teams, err := loadBaseDataTeams(db)
if err != nil {
return BaseDataWorkbook{}, err
}
sports, err := loadBaseDataSports(db)
if err != nil {
return BaseDataWorkbook{}, err
}
events, err := loadBaseDataEvents(db)
if err != nil {
return BaseDataWorkbook{}, err
}
categories, err := loadBaseDataCategories(db)
if err != nil {
return BaseDataWorkbook{}, err
}
return BaseDataWorkbook{Meta: meta, Teams: teams, Sports: sports, Events: events, Categories: categories}, nil
}
func loadBaseDataTeams(db *sql.DB) ([]BaseDataRecord, error) {
rows, err := db.Query(`
SELECT code, name, COALESCE(short_name, ''), COALESCE(color, ''), sort_order, active
FROM teams WHERE deleted_at IS NULL ORDER BY sort_order, code`)
if err != nil {
return nil, fmt.Errorf("export: 读取单位: %w", err)
}
defer func() { _ = rows.Close() }()
var out []BaseDataRecord
for rows.Next() {
var (
code, name, shortName, color string
sortOrder, active int
)
if err := rows.Scan(&code, &name, &shortName, &color, &sortOrder, &active); err != nil {
return nil, fmt.Errorf("export: 扫描单位: %w", err)
}
out = append(out, BaseDataRecord{
"code": code, "name": name, "shortName": shortName, "color": color,
"sortOrder": sortOrder, "active": boolCell(active != 0),
})
}
return out, rows.Err()
}
func loadBaseDataSports(db *sql.DB) ([]BaseDataRecord, error) {
rows, err := db.Query(`
SELECT code, name, COALESCE(short_name, ''), sort_order, active
FROM sports ORDER BY sort_order, code`)
if err != nil {
return nil, fmt.Errorf("export: 读取大项: %w", err)
}
defer func() { _ = rows.Close() }()
var out []BaseDataRecord
for rows.Next() {
var (
code, name, shortName string
sortOrder, active int
)
if err := rows.Scan(&code, &name, &shortName, &sortOrder, &active); err != nil {
return nil, fmt.Errorf("export: 扫描大项: %w", err)
}
out = append(out, BaseDataRecord{
"code": code, "name": name, "shortName": shortName,
"sortOrder": sortOrder, "active": boolCell(active != 0),
})
}
return out, rows.Err()
}
func loadBaseDataEvents(db *sql.DB) ([]BaseDataRecord, error) {
rows, err := db.Query(`
SELECT COALESCE(s.code, ''), e.code, e.name, e.event_type, e.result_mode, e.result_type,
e.compare, e.scored, e.record_eligible, e.multiplier, e.status, e.avg_mode,
e.sort_order, COALESCE(e.venue, ''), COALESCE(e.remark, '')
FROM events e
LEFT JOIN sports s ON s.id = e.sport_id
ORDER BY COALESCE(s.sort_order, 0), e.sort_order, e.code`)
if err != nil {
return nil, fmt.Errorf("export: 读取小项: %w", err)
}
defer func() { _ = rows.Close() }()
var out []BaseDataRecord
for rows.Next() {
var (
sportCode, code, name string
eventType, resultMode, resultType, cmpare string
status, avgMode, venue, remark string
scored, recordEligible, sortOrder int
multiplier float64
)
if err := rows.Scan(&sportCode, &code, &name, &eventType, &resultMode, &resultType,
&cmpare, &scored, &recordEligible, &multiplier, &status, &avgMode,
&sortOrder, &venue, &remark); err != nil {
return nil, fmt.Errorf("export: 扫描小项: %w", err)
}
out = append(out, BaseDataRecord{
"sportCode": sportCode, "code": code, "name": name,
"eventType": eventType, "resultMode": resultMode, "resultType": resultType,
"compare": cmpare, "scored": boolCell(scored != 0), "recordEligible": boolCell(recordEligible != 0),
"multiplier": multiplier, "status": status, "avgMode": avgMode,
"sortOrder": sortOrder, "venue": venue, "remark": remark,
})
}
return out, rows.Err()
}
func loadBaseDataCategories(db *sql.DB) ([]BaseDataRecord, error) {
rows, err := db.Query(`
SELECT COALESCE(e.code, ''), c.name, c.multiplier, COALESCE(c.gender, ''),
c.age_min, c.age_max, c.sort_order
FROM event_categories c
LEFT JOIN events e ON e.id = c.event_id
ORDER BY COALESCE(e.sort_order, 0), e.code, c.sort_order, c.id`)
if err != nil {
return nil, fmt.Errorf("export: 读取组别: %w", err)
}
defer func() { _ = rows.Close() }()
var out []BaseDataRecord
for rows.Next() {
var (
eventCode, name, gender string
multiplier float64
ageMin, ageMax sql.NullInt64
sortOrder int
)
if err := rows.Scan(&eventCode, &name, &multiplier, &gender, &ageMin, &ageMax, &sortOrder); err != nil {
return nil, fmt.Errorf("export: 扫描组别: %w", err)
}
rec := BaseDataRecord{
"eventCode": eventCode, "name": name, "multiplier": multiplier,
"gender": gender, "sortOrder": sortOrder,
}
rec["ageMin"], rec["ageMax"] = "", ""
if ageMin.Valid {
rec["ageMin"] = int(ageMin.Int64)
}
if ageMax.Valid {
rec["ageMax"] = int(ageMax.Int64)
}
out = append(out, rec)
}
return out, rows.Err()
}
// ---------- 导入侧:读取工作簿 ----------
// BaseDataRow 是基础数据表里的一行数据。
type BaseDataRow struct {
// Line 是该行在工作表里的真实行号(1 起,含表头上方的标题行),行级报错直接用它。
Line int
// Cells 是「字段名 → 单元格原文(已去空白)」;Has 标记该字段的列有没有出现在表头里。
//
// 这里的 Has / 空格之分是留给上层的原料,具体怎么解释按列的性质定(解析只负责如实报告):
// - 文本列 / 年龄 / 布尔 / 权重 / 排序:列缺席 = 不动,列在而格子空 = 按该列口径处理
// (文本与年龄清空、布尔取「是」、权重取 1、排序保持原值)——与三处用户文档一致;
// - 枚举列(小项的 类型/结果模式/结果类型/比较/状态/平均口径、组别的 性别):
// 列缺席与格子空**都** = 不动,只有显式填值才覆盖。留空曾按「取系统默认」处理,
// 一次误清格就会把已发布小项打回待录入、把团体项目类型改成个人 —— 见 planEvents。
Cells map[string]string
Has map[string]bool
}
// BaseDataTable 是从工作簿里解析出的一张表。
type BaseDataTable struct {
Sheet string
HeaderLine int
Rows []BaseDataRow
}
// maxHeaderScanRows 是表头检测的扫描范围。
// 本系统的导出件在表头上方有标题与副标题(表头在第 4 行),手工整理的表通常表头在第 1 行,
// 再往上加两三行说明也有可能;扫前 20 行足够,又不会被中间某行数据误判成表头。
const maxHeaderScanRows = 20
// ReadBaseDataPackage 解析基础数据包工作簿,按工作表返回表头位置与数据行。
// 只认 baseDataSheets 里的四个中文表名,别的工作表一律忽略;一张都没找到时返回错误。
func ReadBaseDataPackage(r io.Reader) ([]BaseDataTable, error) {
f, err := excelize.OpenReader(r)
if err != nil {
return nil, fmt.Errorf("export: 打开基础数据包: %w", err)
}
defer func() { _ = f.Close() }()
sheets := f.GetSheetList()
// 认表用去空白后的名字(现场整理过的表常带尾随空格),取表必须用**原名**:
// 之前拿 spec.Sheet 去 GetRows,表就在文件里却报「sheet 单位 does not exist」——
// 一句与事实相反的错,排障时最容易把人带偏。
actual := make(map[string]string, len(sheets))
for _, name := range sheets {
actual[strings.TrimSpace(name)] = name
}
tables := make([]BaseDataTable, 0, len(baseDataSheets))
for _, spec := range BaseDataSpecs() {
name, ok := actual[spec.Sheet]
if !ok {
continue
}
// 报错与行级提示一律用契约里的表名(spec.Sheet),不用带空格的原名,
// 免得同一张表在错误信息里出现两种写法。
rows, err := f.GetRows(name)
if err != nil {
return nil, fmt.Errorf("export: 读取工作表 %s: %w", spec.Sheet, err)
}
table, err := parseBaseDataSheet(spec, rows)
if err != nil {
return nil, err
}
tables = append(tables, table)
}
if len(tables) == 0 {
return nil, fmt.Errorf("export: 基础数据包缺少可识别的工作表(应为 %s)", baseDataSheetNames())
}
return tables, nil
}
func baseDataSheetNames() string {
names := make([]string, 0, len(baseDataSheets))
for _, s := range baseDataSheets {
names = append(names, s.sheet)
}
return strings.Join(names, " / ")
}
// parseBaseDataSheet 在表头行定位成功后,把其后的数据行逐行摊成「字段 → 原文」。
// 整行为空的行(用户在表尾留的空行)直接跳过,不产生「必填」误报。
func parseBaseDataSheet(spec BaseDataSheetSpec, rows [][]string) (BaseDataTable, error) {
headerLine, index := findBaseDataHeader(spec, rows)
if headerLine < 0 {
return BaseDataTable{}, fmt.Errorf("export: 工作表 %s 找不到表头行:应有一行同时包含 %s(本系统导出的包把表头放在标题行下方)",
spec.Sheet, strings.Join(requiredHeaders(spec), "、"))
}
table := BaseDataTable{Sheet: spec.Sheet, HeaderLine: headerLine + 1}
for i := headerLine + 1; i < len(rows); i++ {
row := rows[i]
cells := make(map[string]string, len(index))
has := make(map[string]bool, len(index))
blank := true
for _, h := range spec.Headers {
col, ok := index[h.Field]
if !ok {
continue
}
has[h.Field] = true
v := ""
if col < len(row) {
v = strings.TrimSpace(row[col])
}
cells[h.Field] = v
if v != "" {
blank = false
}
}
if blank {
continue
}
table.Rows = append(table.Rows, BaseDataRow{Line: i + 1, Cells: cells, Has: has})
}
return table, nil
}
// findBaseDataHeader 找出表头行:返回行下标(0 起)与「字段 → 列下标」。
// 判据是该行必须同时出现全部必填表头,避免把标题行或数据行认成表头。
func findBaseDataHeader(spec BaseDataSheetSpec, rows [][]string) (int, map[string]int) {
limit := len(rows)
if limit > maxHeaderScanRows {
limit = maxHeaderScanRows
}
for i := 0; i < limit; i++ {
byHeader := make(map[string]int, len(rows[i]))
for j, cell := range rows[i] {
text := strings.TrimSpace(cell)
if _, dup := byHeader[text]; !dup {
byHeader[text] = j
}
}
index := make(map[string]int, len(spec.Headers))
missing := false
for _, h := range spec.Headers {
col, ok := byHeader[h.Header]
if !ok {
if h.Required {
missing = true
break
}
continue
}
index[h.Field] = col
}
if !missing {
return i, index
}
}
return -1, nil
}
func requiredHeaders(spec BaseDataSheetSpec) []string {
out := make([]string, 0, len(spec.Headers))
for _, h := range spec.Headers {
if h.Required {
out = append(out, h.Header)
}
}
return out
}
// ---------- 导入侧用的小工具 ----------
// ParseBaseDataBool 解析「是 / 否」型单元格:空值取 fallback,认不出的写法返回 false。
func ParseBaseDataBool(raw string, fallback bool) (bool, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "":
return fallback, true
case "是", "1", "true", "yes", "y", "启用", "on":
return true, true
case "否", "0", "false", "no", "n", "停用", "off":
return false, true
default:
return false, false
}
}
// ParseBaseDataFloat 解析数值列;空值取 fallback。
func ParseBaseDataFloat(raw string, fallback float64) (float64, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return fallback, true
}
v, err := strconv.ParseFloat(text, 64)
if err != nil {
return 0, false
}
return v, true
}
// ParseBaseDataInt 解析整数列;空值返回 nil。返回的第二个值表示是否合法。
func ParseBaseDataInt(raw string) (*int, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return nil, true
}
v, err := strconv.Atoi(text)
if err != nil {
return nil, false
}
return &v, true
}