feat(import): 报名模板按「小项 × 组别」分工作表;号码布判重改认证件号;备份改原子落盘
三件事合成一次提交(其中两件都落在 handlers_config.go,无法按文件拆开): 一、分工作表报名模板(新增能力) - 新增 GET /api/entries/template.xlsx:按当前赛事的「小项 × 组别」生成模板, 一个小项组别一张工作表,工作表名 = <小项>@<组别>(31 字符上限、重名自动去重)。 - 导入解析器认工作表名还原小项 + 组别;若与表内列冲突则报错,不静默取其一。 - 小项名与组别名禁用 @,基础数据界面 / 规则包导入 / 服务端校验三处一并拦截。 - 单表 CSV/XLSX 导入路径不回归。 二、号码布判重(修误判) - 旧逻辑依赖证件号本身,而证件号是可选字段 → 同一个人报多个项目会被整块拦下。 - 改为:号码布相同且证件号相同 → 判定为同一人报多项,放行; 号码布相同且证件号都能填且不同 → 仍报错(真抢号);一方证件号为空 → 放行并给提示。 - 团体行的号码布一并纳入查重。 三、自动备份的关停与落盘(修数据安全隐患) - export.Service 增加 Wait():StartAuto 的协程进 WaitGroup,关停时 join(10 秒上限, 超时只记 WARN 不拖死关闭),不再在备份写到一半时退出。 - vacuumInto 改为先写 <最终名>.tmp-<随机> 再 rename,失败则删临时文件; Backup / SafetySnapshot / ExportProjectZip 三个调用方一并受益。 - 新增 isFinalBackupName,ListBackups / safetyCopies / KindOfBackupName 统一过滤中间文件, 避免半截 .db 被列进备份列表、进而被「一键恢复」选中。 文档:docs/ 五份(使用手册 / 报名名单导入规范 / 升级说明 / 部署指南 / 上线检查清单)同步。
This commit is contained in:
1 parent
b74768b7d5
commit
1b8a37ff8c
32 files changed
+3302
-233
No files matched your search
@@ -147,6 +147,11 @@ type Service struct {
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
|
||||
// wg 记着 StartAuto 起的后台协程,供 Wait 在进程退出前 join。
|
||||
// 只加不减:协程退出后 Wait 照常立即返回,所以「没起过自动备份」与
|
||||
// 「自动备份已经停了」两种情况下 Wait 都是零成本。
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewService 构造备份服务;keep<=0 时按 DefaultBackupKeep 滚动保留。
|
||||
@@ -184,7 +189,9 @@ func (s *Service) StartAuto(ctx context.Context, interval time.Duration) {
|
||||
s.logger.Warn("未配置数据目录,跳过自动备份")
|
||||
return
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -208,6 +215,22 @@ func (s *Service) StartAuto(ctx context.Context, interval time.Duration) {
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait 等 StartAuto 起的后台协程退出,含「已经在飞、正卡在 VACUUM INTO 里」的那一轮备份。
|
||||
//
|
||||
// 调用点只有服务端 main:srv.Shutdown 之后、关库之前。不 join 的话,进程可能正卡在
|
||||
// VACUUM INTO 中途就退出——那一轮备份会停在半路(见 vacuumInto 的原子落盘,最坏也只是
|
||||
// 留下一个中间文件,但「停止写盘」这件事必须等出来才做数)。
|
||||
//
|
||||
// 不阻断、不改变任何既有语义:没起过自动备份(间隔 <=0、没配数据目录)时立即返回;
|
||||
// 它不取消上下文、也不打断正在跑的备份,只是等它们收尾。ctx 取消(含
|
||||
// POST /api/shutdown 复用的那个信号)仍然由调用方负责,本方法只解决「等没等到」。
|
||||
//
|
||||
// 注意:别再拿同一个 Service 在 Wait 期间调 StartAuto(sync.WaitGroup 的复用限制),
|
||||
// 服务端一生只调一次。
|
||||
func (s *Service) Wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) snapshotAll(reason string, withSystem bool) ([]BackupInfo, error) {
|
||||
if strings.TrimSpace(s.dir) == "" {
|
||||
return nil, ErrNoDir
|
||||
@@ -246,6 +269,10 @@ func (s *Service) snapshot(db *sql.DB, prefix, reason string) (BackupInfo, error
|
||||
|
||||
// ListBackups 按时间倒序列出备份;prefix 为空时列出全部 .db 备份。
|
||||
// 不计算校验和(避免每次列表都全量读盘),需要校验时用 Backup 的返回值。
|
||||
//
|
||||
// 只认「已经落好的」备份文件(isFinalBackupName):中间文件一律不列。
|
||||
// 备份列表是「一键恢复」选文件的唯一数据源,把一份没写完的文件列出去,
|
||||
// 现场就可能拿它去覆盖好库——这比没有备份更糟。
|
||||
func ListBackups(dir, prefix string) ([]BackupInfo, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
@@ -258,7 +285,7 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
out := make([]BackupInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
|
||||
if e.IsDir() || !isFinalBackupName(e.Name()) {
|
||||
continue
|
||||
}
|
||||
if prefix != "" && !strings.HasPrefix(e.Name(), prefix+"-") {
|
||||
@@ -285,6 +312,23 @@ func ListBackups(dir, prefix string) ([]BackupInfo, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isFinalBackupName 判断目录项是不是一份「已经落好的」备份文件。
|
||||
//
|
||||
// 备份目录里会短暂(异常退出后则是长期)存在两类非最终文件,都必须挡住:
|
||||
// - VACUUM INTO 的中间文件 <最终名>.tmp-<随机>(见 vacuumInto);
|
||||
// - 任何别的写法留下的 .tmp 临时文件。
|
||||
//
|
||||
// 判据是「.db 结尾」且名字里的主干不含 tmpMark。备份名由本包生成
|
||||
// (<库>-<原因>-<时间戳>[-序号].db,原因只有 manual / auto / publish / pre-restore),
|
||||
// 正常备份不可能命中这个标记。
|
||||
func isFinalBackupName(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
if !strings.HasSuffix(lower, ".db") {
|
||||
return false
|
||||
}
|
||||
return !strings.Contains(strings.TrimSuffix(lower, ".db"), tmpMark)
|
||||
}
|
||||
|
||||
// LatestBackup 返回最近一份备份(ListBackups 已按时间倒序,取首个)。
|
||||
//
|
||||
// 注意:它**不是**健康检查的输入——05 §9 明确说 GET /api/health 不返回「最近备份时间」,
|
||||
|
||||
@@ -69,9 +69,13 @@ func (k RestoreKind) safetyPrefix() string { return "pre-restore-" + string(k) }
|
||||
// 认不出来的名字(例如老版本的 db- 前缀)一律不认——恢复请求的文件名只允许来自备份目录,
|
||||
// 含糊的名字不进恢复流程。返回的 kind 用于显示(system / event / safety),
|
||||
// dbKind 是它对应的库——恢复要覆盖的是这一份。
|
||||
//
|
||||
// 中间文件(VACUUM INTO 写到一半、或上一次异常退出留下的 <备份名>.tmp-<随机>)同样不认:
|
||||
// 它没写完,更不该被「一键恢复」选中——这一条与 ListBackups 的过滤是同一口径,
|
||||
// 挡住的是「绕过列表、直接提交文件名」的调用方(列表本身已经不列它们了)。
|
||||
func KindOfBackupName(name string) (kind string, dbKind RestoreKind, ok bool) {
|
||||
base := strings.ToLower(filepath.Base(strings.TrimSpace(name)))
|
||||
if !strings.HasSuffix(base, ".db") {
|
||||
if !isFinalBackupName(base) {
|
||||
return "", "", false
|
||||
}
|
||||
stem := strings.TrimSuffix(base, ".db")
|
||||
@@ -262,7 +266,10 @@ func safetyCopies(dir string, kind RestoreKind) ([]BackupInfo, error) {
|
||||
prefix := kind.safetyPrefix() + "-"
|
||||
out := make([]BackupInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".db") {
|
||||
// 与 ListBackups 同一口径:中间文件不是副本。
|
||||
// 这一条还决定 PruneSafetyCopies 删谁——把没写完的中间文件算进保留份数,
|
||||
// 会在副本数量紧张时先把正经副本删掉。
|
||||
if e.IsDir() || !isFinalBackupName(e.Name()) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(e.Name()), prefix) {
|
||||
|
||||
@@ -57,6 +57,12 @@ func TestKindOfBackupName(t *testing.T) {
|
||||
{"pre-restore-event.db", "", "", false},
|
||||
{"team-restore.db", "", "", false},
|
||||
{"event-auto-20260911-120000.txt", "", "", false},
|
||||
// 中间文件(写到一半 / 上一次异常退出的残留)也不是备份:
|
||||
// 前缀对得上也不能进恢复流程,否则就是拿一份没写完的文件覆盖好库。
|
||||
{"event-auto-20260911-120000.db.tmp-0123456789abcdef", "", "", false},
|
||||
{"pre-restore-event-20260911-120000.db.tmp-0123456789abcdef", "", "", false},
|
||||
{"event-auto-20260911-120000.tmp.db", "", "", false},
|
||||
{"EVENT-AUTO-20260911-120000.TMP-0123456789ABCDEF", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
kind, dbKind, ok := KindOfBackupName(c.name)
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
|
||||
)
|
||||
|
||||
// TestWaitJoinInFlightBackupLeavesNoHalfFile 钉住「关闭时备份正在进行」这条回归。
|
||||
//
|
||||
// 关停(取消上下文 + Wait)之后,备份目录里不允许出现半截的最终 .db,也不允许留下中间文件:
|
||||
// 在飞的那一轮要么完整写完,要么什么都没留下。
|
||||
//
|
||||
// 「备份正在进行」在这里是确定复现的,不靠撞时序:另一条连接先抢走库的写锁。
|
||||
// VACUUM INTO 的顺序是「先按目标名建出空文件、再读源库」,源库被锁住时它会带着一个
|
||||
// 0 字节的目标文件停在锁上(等到 busy_timeout)——这正是进程退出会撞上的那一刻。
|
||||
func TestWaitJoinInFlightBackupLeavesNoHalfFile(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "event.db")
|
||||
db := openEventDBLockable(t, dbPath, 5000)
|
||||
seedEvent(t, db)
|
||||
dir := t.TempDir()
|
||||
|
||||
release := lockEventDB(t, dbPath)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
svc := NewService(nil, db, dir, 20, testLogger())
|
||||
// 注册在最前(LIFO 最后执行的反面):先取消、再放锁、最后 join,
|
||||
// 这样用例中途失败时也不会把「等锁的备份」和「删目录」搅在一起。
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
release()
|
||||
svc.Wait()
|
||||
})
|
||||
svc.StartAuto(ctx, 5*time.Millisecond)
|
||||
|
||||
// 备份确实开始写了:目录里已经出现它正在写的文件(中间文件或半截的最终文件)。
|
||||
waitForDirEntry(t, dir, 5*time.Second)
|
||||
|
||||
// 要害在这里:写到一半的备份绝不能出现在备份列表里。
|
||||
// GET /api/backups 与「一键恢复」选文件都走 ListBackups,列出去就等于告诉现场
|
||||
// 「这份备份能用」——而它此刻还是 0 字节。
|
||||
if list, err := ListBackups(dir, ""); err != nil {
|
||||
t.Fatalf("列出备份: %v", err)
|
||||
} else if len(list) != 0 {
|
||||
t.Fatalf("写到一半的备份出现在备份列表里:%+v(正常关闭后它会变成永久残留)", list)
|
||||
}
|
||||
|
||||
cancel()
|
||||
stopped := make(chan struct{})
|
||||
go func() { svc.Wait(); close(stopped) }()
|
||||
// 备份还卡在 VACUUM INTO 里,Wait 不能提前返回:提前返回意味着进程会在这一刻退出。
|
||||
select {
|
||||
case <-stopped:
|
||||
t.Fatal("备份还卡在 VACUUM INTO 里,Wait 就返回了;进程此时退出会留下半截文件")
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
// 放锁:在飞的那一轮会正常写完,Wait 要等到它收尾。
|
||||
release()
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("放锁后 10 秒内 Wait 都没返回")
|
||||
}
|
||||
|
||||
assertOnlyCompleteBackups(t, dir)
|
||||
}
|
||||
|
||||
// TestListBackupsIgnoresTempFiles 钉住列表过滤:备份目录里的中间文件
|
||||
// (VACUUM INTO 写到一半、或上一次异常退出留下的)不能出现在备份列表里,
|
||||
// 也不能被「一键恢复」的文件名校验认成备份——哪怕有人手工把它放进目录。
|
||||
func TestListBackupsIgnoresTempFiles(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
dir := t.TempDir()
|
||||
|
||||
info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "auto", Keep: 20})
|
||||
if err != nil {
|
||||
t.Fatalf("备份: %v", err)
|
||||
}
|
||||
|
||||
// 三类「不是最终备份」的东西,混在真备份旁边。
|
||||
strays := []string{
|
||||
// 本包的中间文件名:<最终名>.tmp-<随机>。
|
||||
info.Path + tmpMark + "-0123456789abcdef",
|
||||
// 另一种写法的临时文件:仍然是 .db 结尾、前缀也对得上,只有名字里的标记能识破。
|
||||
filepath.Join(dir, "event-auto-20260101-120000.tmp.db"),
|
||||
// 安全副本那份列表(PruneSafetyCopies 用它决定删谁)也要挡住的中间文件。
|
||||
filepath.Join(dir, "pre-restore-event-20260101-120000.tmp-abc.db"),
|
||||
// 看着像备份、但不是库文件。
|
||||
filepath.Join(dir, "event-auto-20260101-120000.txt"),
|
||||
}
|
||||
for _, p := range strays {
|
||||
if err := os.WriteFile(p, []byte("半截内容"), 0o644); err != nil {
|
||||
t.Fatalf("放置中间文件 %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
list, err := ListBackups(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("列出备份: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Name != info.Name {
|
||||
t.Fatalf("备份列表 = %v,期望只有 %s(中间文件必须被过滤掉)", backupNames(list), info.Name)
|
||||
}
|
||||
if list, err := ListBackups(dir, "event"); err != nil {
|
||||
t.Fatalf("按前缀列出备份: %v", err)
|
||||
} else if len(list) != 1 {
|
||||
t.Fatalf("按前缀列出 %d 份,期望 1 份", len(list))
|
||||
}
|
||||
latest, ok, err := LatestBackup(dir, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("取最近备份: %v", err)
|
||||
}
|
||||
if !ok || latest.Name != info.Name {
|
||||
t.Fatalf("最近备份 = %+v(ok=%v),期望 %s", latest, ok, info.Name)
|
||||
}
|
||||
if copies, err := safetyCopies(dir, RestoreKindEvent); err != nil {
|
||||
t.Fatalf("列出安全副本: %v", err)
|
||||
} else if len(copies) != 0 {
|
||||
t.Fatalf("安全副本列表 = %v,期望为空(中间文件不是副本)", backupNames(copies))
|
||||
}
|
||||
|
||||
// 「一键恢复」按文件名认库:中间文件必须认不出来,否则就轮不到列表那一层拦。
|
||||
for _, p := range strays[:3] {
|
||||
if kind, _, ok := KindOfBackupName(filepath.Base(p)); ok {
|
||||
t.Errorf("KindOfBackupName(%q) 认成了 %q:中间文件不该被一键恢复选中",
|
||||
filepath.Base(p), kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVacuumIntoCleansTempOnFailure 钉住失败路径:VACUUM 失败之后备份目录必须干净。
|
||||
//
|
||||
// 源库被锁住时 VACUUM INTO 会先建出目标文件再失败,这条用例就是冲着「失败时留下的东西」
|
||||
// 来的:中间文件要清掉,最终名下一个字节都不能留(复现手法与上面那条并发用例同一套)。
|
||||
func TestVacuumIntoCleansTempOnFailure(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "event.db")
|
||||
db := openEventDBLockable(t, dbPath, 200)
|
||||
seedEvent(t, db)
|
||||
dir := t.TempDir()
|
||||
|
||||
lockEventDB(t, dbPath)
|
||||
|
||||
if info, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "auto", Keep: 20}); err == nil {
|
||||
t.Fatalf("源库被锁住时备份应当失败,实际成功: %+v", info)
|
||||
}
|
||||
if names := dirNames(t, dir); len(names) != 0 {
|
||||
t.Fatalf("备份失败后目录里留下了 %v,期望空目录(半截 .db 会被当成可用备份)", names)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitReturnsImmediatelyWhenIdle 钉住 Wait 的「不做死」:
|
||||
// 没起过自动备份、自动备份被关掉(间隔 <=0 / 没配数据目录)、上下文已取消之后,
|
||||
// Wait 都要立刻返回;起了协程的则必须能 join 回来。
|
||||
func TestWaitReturnsImmediatelyWhenIdle(t *testing.T) {
|
||||
// 一次都没 StartAuto。
|
||||
svc := NewService(nil, nil, t.TempDir(), 20, testLogger())
|
||||
assertWaitReturns(t, svc)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// 间隔 <=0 = 自动备份关闭,StartAuto 不起协程。
|
||||
svc.StartAuto(ctx, 0)
|
||||
assertWaitReturns(t, svc)
|
||||
// 没配数据目录同理。
|
||||
NewService(nil, nil, "", 20, testLogger()).StartAuto(ctx, time.Minute)
|
||||
assertWaitReturns(t, svc)
|
||||
|
||||
// 起过协程:取消后必须能 join 回来(不能等出个死结)。
|
||||
svc.StartAuto(ctx, time.Hour)
|
||||
cancel()
|
||||
assertWaitReturns(t, svc)
|
||||
}
|
||||
|
||||
// ---- 用例用的小工具 ----
|
||||
|
||||
// openEventDBLockable 与 backup_restore_test.go 的 openEventDBAt 相同,区别是
|
||||
// journal_mode=DELETE(rollback 模式)。用例要的是「另一条连接能真的锁住库」:
|
||||
// WAL 下读不会被写锁挡住,BEGIN EXCLUSIVE 形同虚设。
|
||||
// busyTimeoutMS 用来控制「卡在锁上」的时长(失败路径要快、并发路径要够久)。
|
||||
func openEventDBLockable(t *testing.T, path string, busyTimeoutMS int) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(DELETE)&_pragma=foreign_keys(1)&_pragma=busy_timeout(%d)",
|
||||
path, busyTimeoutMS)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("打开赛事库: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
if err := store.MigrateEvent(db); err != nil {
|
||||
t.Fatalf("迁移赛事库: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// lockEventDB 用另一条连接抢走库文件的写锁(BEGIN EXCLUSIVE),返回放锁函数。
|
||||
// 放锁是幂等的,重复调用无副作用;用例忘了放锁也会在清理时放掉。
|
||||
func lockEventDB(t *testing.T, path string) func() {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
handle, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
|
||||
if err != nil {
|
||||
t.Fatalf("打开锁连接: %v", err)
|
||||
}
|
||||
handle.SetMaxOpenConns(1)
|
||||
conn, err := handle.Conn(ctx)
|
||||
if err != nil {
|
||||
_ = handle.Close()
|
||||
t.Fatalf("取锁连接: %v", err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, `BEGIN EXCLUSIVE`); err != nil {
|
||||
_ = conn.Close()
|
||||
_ = handle.Close()
|
||||
t.Fatalf("抢库写锁: %v", err)
|
||||
}
|
||||
// 显式读一次:锁要落地,不能只挂在事务状态上。
|
||||
var n int
|
||||
if err := conn.QueryRowContext(ctx, `SELECT count(*) FROM teams`).Scan(&n); err != nil {
|
||||
t.Fatalf("持锁读库: %v", err)
|
||||
}
|
||||
|
||||
var once sync.Once
|
||||
release := func() {
|
||||
once.Do(func() {
|
||||
_, _ = conn.ExecContext(ctx, `ROLLBACK`)
|
||||
_ = conn.Close()
|
||||
_ = handle.Close()
|
||||
})
|
||||
}
|
||||
t.Cleanup(release)
|
||||
return release
|
||||
}
|
||||
|
||||
// waitForDirEntry 等备份目录里出现第一个文件(备份确实开始写了),超时即失败。
|
||||
func waitForDirEntry(t *testing.T, dir string, timeout time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if entries, err := os.ReadDir(dir); err != nil {
|
||||
t.Fatalf("读取备份目录 %s: %v", dir, err)
|
||||
} else if len(entries) > 0 {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s 内备份目录 %s 一直没出现文件:备份没开始写", timeout, dir)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// assertWaitReturns 断言 Wait 在给定的等待窗口内返回(无任务时它不该阻塞)。
|
||||
func assertWaitReturns(t *testing.T, svc *Service) {
|
||||
t.Helper()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
svc.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wait 在无任务时没有立即返回")
|
||||
}
|
||||
}
|
||||
|
||||
// assertOnlyCompleteBackups 断言备份目录里只剩下完整可读的备份:
|
||||
// 没有中间文件残留,每个列出来的 .db 都能打开、数据齐(不是半截文件)。
|
||||
func assertOnlyCompleteBackups(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("读取备份目录 %s: %v", dir, err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("等待收尾后一份备份都没有:在飞的那一轮既没写完也没留下任何文件")
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !isFinalBackupName(e.Name()) {
|
||||
t.Errorf("备份目录里残留中间文件 %s:它既不是备份,也不该留着", e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
list, err := ListBackups(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("列出备份: %v", err)
|
||||
}
|
||||
if len(list) != len(entries) {
|
||||
t.Fatalf("目录里 %d 个文件,备份列表只有 %d 份:列表过滤与落盘口径不一致",
|
||||
len(entries), len(list))
|
||||
}
|
||||
for _, b := range list {
|
||||
if b.Size == 0 {
|
||||
t.Errorf("备份 %s 是 0 字节", b.Name)
|
||||
continue
|
||||
}
|
||||
bdb := openSQLite(t, b.Path)
|
||||
if got := countRows(t, bdb, "teams"); got != 2 {
|
||||
t.Errorf("备份 %s 里 teams = %d,期望 2(文件不完整)", b.Name, got)
|
||||
}
|
||||
_ = bdb.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// dirNames 列出目录里的文件名(排序无要求,仅供报错信息)。
|
||||
func dirNames(t *testing.T, dir string) []string {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("读取备份目录 %s: %v", dir, err)
|
||||
}
|
||||
out := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func backupNames(list []BackupInfo) []string {
|
||||
out := make([]string, 0, len(list))
|
||||
for _, b := range list {
|
||||
out = append(out, b.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -153,9 +154,19 @@ func TestStartAutoBacksUp(t *testing.T) {
|
||||
seedEvent(t, db)
|
||||
dir := t.TempDir()
|
||||
|
||||
const interval = 10 * time.Millisecond
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
NewService(nil, db, dir, 20, testLogger()).StartAuto(ctx, 10*time.Millisecond)
|
||||
// 备份跑在后台协程里,而 t.TempDir 的清理是把目录整个删掉:测试一返回它就删,
|
||||
// 协程此刻可能刚提交下一轮备份、正往目录里写新文件,RemoveAll 于是报
|
||||
// 「unlinkat .../002: directory not empty」(本用例历史上就是这样偶发红的)。
|
||||
// 清理按后进先出执行,这条比 t.TempDir 的清理注册得晚、所以先跑:
|
||||
// 先取消 ctx 等协程真正停稳,t.TempDir 再去删就不会撞上还没落完的写。
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
waitAutoBackupIdle(t, dir, interval)
|
||||
})
|
||||
|
||||
NewService(nil, db, dir, 20, testLogger()).StartAuto(ctx, interval)
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
@@ -172,10 +183,70 @@ func TestStartAutoBacksUp(t *testing.T) {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("10 秒内未生成定时备份")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
time.Sleep(interval)
|
||||
}
|
||||
}
|
||||
|
||||
// waitAutoBackupIdle 等自动备份的后台协程停稳后再返回(供测试在删目录前调用)。
|
||||
//
|
||||
// Service.Wait 才是正式入口(StartAuto 起的协程都会被它 join,含已经在飞的那一轮备份);
|
||||
// 这里仍然从目录上观察「还在不在写」,因为这一条不依赖 Service 内部实现,
|
||||
// 能把「Wait 返回了但目录还在变」这种回归直接照出来。协程的退出本身是确定的——
|
||||
// 每轮 select 都先看 ctx.Done,ctx 一取消就返回,最多再走完一轮已经在飞的备份——
|
||||
// 所以「连续若干个 tick 目录都没有新文件落盘」就足以判定它已经停了。
|
||||
func waitAutoBackupIdle(t *testing.T, dir string, interval time.Duration) {
|
||||
t.Helper()
|
||||
|
||||
// 20 个 tick 的安静窗口:远大于单轮备份(这份小库几毫秒)与调度抖动,
|
||||
// 又短到不至于让用例等出可感知的慢。
|
||||
const quietTicks = 20
|
||||
quiet := quietTicks * interval
|
||||
const patience = 5 * time.Second
|
||||
|
||||
deadline := time.Now().Add(patience)
|
||||
last := backupDirState(t, dir)
|
||||
quietSince := time.Now()
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
if cur := backupDirState(t, dir); cur != last {
|
||||
last, quietSince = cur, time.Now()
|
||||
}
|
||||
if time.Since(quietSince) >= quiet {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
// 非 Fatal:这里用 t.Errorf 是为了不打断后面的清理(Fatal 会跳过 t.TempDir 的清理,
|
||||
// 反而把残留目录留在 TMPDIR 里),用例仍然会红。
|
||||
t.Errorf("自动备份协程在 %s 内都没停稳,目录 %s 仍在变化", patience, dir)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// backupDirState 给备份目录拍一张快照,用来判断「还有没有新备份在落盘」。
|
||||
// 快照带上文件名、大小与修改时间:同一秒内的第 2、3 份备份只是文件名多了序号,
|
||||
// 靠修改时间也能把它们与前一份区分开。
|
||||
func backupDirState(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ""
|
||||
}
|
||||
t.Fatalf("读取备份目录 %s: %v", dir, err)
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "%s|%d|%d;", e.Name(), info.Size(), info.ModTime().UnixNano())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// 超过保留份数时删除最旧的备份。
|
||||
func TestServiceRunPrunesOldBackups(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
|
||||
@@ -2,6 +2,7 @@ package export
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
@@ -337,15 +338,59 @@ func SafeProjectName(name string) (string, error) {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// tmpMark 是「中间文件」在文件名里的标记:备份先写成 <最终名>.tmp-<随机>,成功后再改名。
|
||||
// ListBackups / safetyCopies 靠它把这些文件挡在备份列表之外(见 isFinalBackupName)。
|
||||
const tmpMark = ".tmp"
|
||||
|
||||
// vacuumInto 把 db 的快照写进 dst,并且**只以完整文件的形态出现在 dst 这个名字上**:
|
||||
// 先写同目录的中间文件 <dst>.tmp-<随机>,VACUUM 成功后再改名过去。
|
||||
//
|
||||
// 不能直接写 dst。VACUUM INTO 是「先按最终名建出空文件,再往里灌页」:源库一旦被别的
|
||||
// 进程/连接锁住(或有别的写入卡着),它会带着一个 0 字节的目标文件在那里等(实测),
|
||||
// 进程此时退出(正常关闭 / 被 Console 杀掉 / 掉电)就在原地留下半截 .db。
|
||||
// 备份目录里所有 .db 都会被 ListBackups 列出来、被「一键恢复」当作可恢复的文件选中,
|
||||
// 而备份是最后一道防线——它自己不能是个坏文件。
|
||||
//
|
||||
// 中间文件与最终文件同目录,改名不跨文件系统;失败(含 VACUUM 中途失败)时中间文件会被删掉。
|
||||
func vacuumInto(db *sql.DB, dst string) error {
|
||||
tmp, err := vacuumTempPath(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
|
||||
stmt := "VACUUM INTO '" + strings.ReplaceAll(dst, "'", "''") + "'"
|
||||
stmt := "VACUUM INTO '" + strings.ReplaceAll(tmp, "'", "''") + "'"
|
||||
if _, err := db.Exec(stmt); err != nil {
|
||||
// 半截的中间文件既不是备份,也不该在备份目录里攒着——删不掉不算新错误,
|
||||
// 照原样回报 VACUUM 的失败(它才是操作员要看的原因)。
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("export: 提交备份文件 %s: %w", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vacuumTempPath 给一次 VACUUM INTO 挑一个中间文件名。
|
||||
//
|
||||
// 只挑名字、不建文件:VACUUM INTO 拒绝写已经存在的目标文件。随机串用来避开
|
||||
// 「上一次异常退出留下的同名中间文件」与「并发的 CLI 备份(scoreboard backup)」。
|
||||
func vacuumTempPath(dst string) (string, error) {
|
||||
dir, base := filepath.Split(dst)
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
return "", fmt.Errorf("export: 生成备份中间文件名: %w", err)
|
||||
}
|
||||
p := filepath.Join(dir, base+tmpMark+"-"+hex.EncodeToString(buf[:]))
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return "", fmt.Errorf("export: 备份中间文件名 %s 已存在,请清理后重试", p)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("export: 检查备份中间文件 %s: %w", p, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func buildManifest(root string) ([]ProjectFile, error) {
|
||||
var out []ProjectFile
|
||||
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 分工作表报名模板:**一张工作表 = 一个小项 × 一个组别**,表名即「小项名称@组别名称」。
|
||||
//
|
||||
// 现场把 40 个小项 × 2 个组别塞进同一张表时,填表人得自己在「项目编码 / 组别」两列里反复抄,
|
||||
// 抄错一个字这一行就报「小项 XXX 不存在」。拆成一张表一个组合后,范围由**表名**承担,
|
||||
// 数据区只剩「谁、哪个单位」这些真正要人填的东西。
|
||||
//
|
||||
// 命名与识别是**同一份实现**,生成端(下载模板)与解析端(导入报名表)都走 newEntrySheetPlan:
|
||||
// 双方各自把当前赛事的 (小项, 组别) 组合算成「工作表名 → 范围」的映射,导入时按表名查这张映射表。
|
||||
// 好处是解析永远是**精确命中**——没有前缀匹配、没有猜名字的启发式:改过名的小项或组别会让旧模板
|
||||
// 对不上号,这时宁可报错让现场重新下载,也不能把两张表的数据并到一起。
|
||||
//
|
||||
// 表名必须落进 Excel 的限制里(最长 31 个 UTF-16 单元、禁用 : \ / ? * [ ]、同簿内不能重名),
|
||||
// 所以下面每一处截断与去重都必须是确定性的:同一份基础数据算出来的表名,任何时候都完全一样。
|
||||
|
||||
const (
|
||||
// entrySheetMaxUnits 是 Excel 对工作表名的硬限制(UTF-16 编码单元数,汉字算 1 个)。
|
||||
entrySheetMaxUnits = 31
|
||||
// entrySheetMinPartUnits 是截断时一侧至少保留的字符数:再短就认不出是哪一项了。
|
||||
entrySheetMinPartUnits = 8
|
||||
// entrySheetSeparator 是小项与组别的分隔符。小项名与组别名里都不准出现它。
|
||||
entrySheetSeparator = "@"
|
||||
// entrySheetSuffixLimit 是撞名去重后缀的编号上限,纯粹是防死循环的兜底。
|
||||
entrySheetSuffixLimit = 9999
|
||||
)
|
||||
|
||||
// atSignNameMessage 是「名字里含 @」的统一理由文案。
|
||||
//
|
||||
// 三处拦截(基础数据界面、规则包 YAML、服务端接口)说的必须是同一件事:@ 是分工作表模板里
|
||||
// 小项与组别的分隔符,写进名字会让「小项@组别」拆不开。只回一句「不能含 @」现场只会换个位置再撞。
|
||||
func atSignNameMessage(kind, name string) string {
|
||||
return fmt.Sprintf("%s「%s」里不能有 @(也不能有全角 @):%s 是分工作表报名模板里「小项名称%s组别名称」的分隔符,"+
|
||||
"写进名字会让工作表名拆不开、模板生成不出来。请换成别的字符(例如 - 或 ·)", kind, name,
|
||||
entrySheetSeparator, entrySheetSeparator)
|
||||
}
|
||||
|
||||
// nameSeparatorProblem 返回名字里的 @ 问题说明;没问题时返回空串。
|
||||
//
|
||||
// 返回字符串而不是 error,是为了让两条调用路径共用同一份判据:接口层要 badRequest(打回请求),
|
||||
// 基础数据包导入要行级报错(其余行照常导入)。校验层写两遍是历史伤疤的来源——
|
||||
// 只改一处就会出现「界面上拦住了、导 Excel 却能塞进去」。
|
||||
func nameSeparatorProblem(kind, name string) string {
|
||||
// 全角 @ 与半角 @ 在表名里肉眼几乎分不出,一并拦下:现场没有正当理由用它。
|
||||
idx := strings.IndexAny(name, "@@")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return atSignNameMessage(kind, name)
|
||||
}
|
||||
|
||||
// checkNameSeparator 是接口层的 @ 校验:小项名、组别名都走它。
|
||||
func checkNameSeparator(kind, name string) error {
|
||||
if msg := nameSeparatorProblem(kind, name); msg != "" {
|
||||
return badRequest(msg, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// entrySheetScope 是一个 (小项, 组别) 组合,即生成端要建的一张工作表。
|
||||
type entrySheetScope struct {
|
||||
EventID int64 `json:"eventId"`
|
||||
EventCode string `json:"eventCode"`
|
||||
EventName string `json:"eventName"`
|
||||
CategoryID int64 `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
}
|
||||
|
||||
// entrySheetPlanned 是一张待生成的工作表:名字 + 它代表的范围。
|
||||
type entrySheetPlanned struct {
|
||||
Name string
|
||||
Scope entrySheetScope
|
||||
}
|
||||
|
||||
// entrySheetPlan 是「工作表名 → 范围」的确定性映射,下载与导入共用。
|
||||
type entrySheetPlan struct {
|
||||
sheets []entrySheetPlanned
|
||||
// byKey 的键是 entrySheetKey(工作表名),值是它代表的范围。
|
||||
byKey map[string]entrySheetScope
|
||||
// eventByKey 是「小项名归一化键 → 该小项的组别组合」,只在表名对不上时给现场一句能照着改的诊断。
|
||||
eventByKey map[string][]entrySheetScope
|
||||
// excluded 是名字里含 @(或全角 @)而**没法**写进工作表名的小项 / 组别。
|
||||
// 生成端据此拒绝下载并点名让人先改名;解析端不把它们放进映射(这些组合本来就表达不出来)。
|
||||
excluded []string
|
||||
}
|
||||
|
||||
// loadEntrySheetScopes 读出当前赛事的全部 (小项, 组别) 组合。
|
||||
//
|
||||
// 排序即工作表的先后与撞名去重的先后,必须是确定的:现场从上到下就是基础数据里的顺序
|
||||
// (小项按排序值 + 编码,组别按排序值 + id);换个顺序会让「(2)」后缀落到另一张表上,
|
||||
// 解析端也就跟着错位。
|
||||
func (s *server) loadEntrySheetScopes(ctx context.Context) ([]entrySheetScope, error) {
|
||||
rows, err := s.ev.QueryContext(ctx,
|
||||
`SELECT e.id, COALESCE(e.code, ''), e.name, c.id, c.name
|
||||
FROM events e
|
||||
JOIN event_categories c ON c.event_id = e.id
|
||||
ORDER BY e.sort_order, e.code, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make([]entrySheetScope, 0, 16)
|
||||
for rows.Next() {
|
||||
var scope entrySheetScope
|
||||
if err := rows.Scan(&scope.EventID, &scope.EventCode, &scope.EventName,
|
||||
&scope.CategoryID, &scope.CategoryName); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
out = append(out, scope)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, internal(err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// newEntrySheetPlan 给每个组合定一个工作表名。scopes 必须按确定顺序传入(见 loadEntrySheetScopes)。
|
||||
//
|
||||
// 撞名去重:Excel 一个工作簿里不允许两张同名工作表,重名的后果是**后一张把前一张覆盖掉**,
|
||||
// 数据无声消失。所以名字重复时在尾部追加 (2)、(3)…,并把名字再截短以塞进 31 字符;
|
||||
// 去重比的是归一化后的键(entrySheetKey),半角 / 全角、空格、大小写不同不算两张表的区别。
|
||||
func newEntrySheetPlan(scopes []entrySheetScope) entrySheetPlan {
|
||||
plan := entrySheetPlan{
|
||||
sheets: make([]entrySheetPlanned, 0, len(scopes)),
|
||||
byKey: make(map[string]entrySheetScope, len(scopes)),
|
||||
eventByKey: make(map[string][]entrySheetScope, len(scopes)),
|
||||
}
|
||||
used := make(map[string]bool, len(scopes))
|
||||
seenPair := make(map[[2]int64]bool, len(scopes))
|
||||
|
||||
for _, scope := range scopes {
|
||||
// 同一对小项 + 组别只出一张表:重复的组合只会多一张内容一样的表,还会挤掉别人的名字。
|
||||
pair := [2]int64{scope.EventID, scope.CategoryID}
|
||||
if seenPair[pair] {
|
||||
continue
|
||||
}
|
||||
seenPair[pair] = true
|
||||
|
||||
if problem := nameSeparatorProblem("小项名称", scope.EventName); problem != "" {
|
||||
plan.excluded = append(plan.excluded, problem)
|
||||
continue
|
||||
}
|
||||
if problem := nameSeparatorProblem("组别名称", scope.CategoryName); problem != "" {
|
||||
plan.excluded = append(plan.excluded, problem)
|
||||
continue
|
||||
}
|
||||
|
||||
name := entrySheetName(scope.EventName, scope.CategoryName)
|
||||
key := entrySheetKey(name)
|
||||
for n := 2; used[key] && n <= entrySheetSuffixLimit; n++ {
|
||||
// 加后缀会让名字变长,先把尾部截掉凑出空间:后缀甩到 31 字符外,Excel 直接拒绝建表。
|
||||
suffix := fmt.Sprintf("(%d)", n)
|
||||
name = headWithinUnits(name, entrySheetMaxUnits-unitsOf(suffix)) + suffix
|
||||
key = entrySheetKey(name)
|
||||
}
|
||||
used[key] = true
|
||||
|
||||
plan.sheets = append(plan.sheets, entrySheetPlanned{Name: name, Scope: scope})
|
||||
plan.byKey[key] = scope
|
||||
eventKey := entrySheetKey(scope.EventName)
|
||||
plan.eventByKey[eventKey] = append(plan.eventByKey[eventKey], scope)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
// entrySheetName 拼一张工作表的名字:<小项名称>@<组别名称>。
|
||||
//
|
||||
// 截断策略(确定性,两侧都从**尾部**截,保留开头——人一眼能认出是哪一项):
|
||||
// 1. 小项名优先保留,但至少给组别名留下 8 个字符(组别名太短就分不出是哪一组);
|
||||
// 2. 小项名先截到自己那份额度,剩下的全给组别名;
|
||||
// 3. 预算按 UTF-16 单元算(汉字 1、emoji 2),与 Excel 的计数口径一致。
|
||||
//
|
||||
// 截断后名字仍是原名的一段前缀——这是解析端「重新算一遍映射」能对上的前提。
|
||||
func entrySheetName(eventName, categoryName string) string {
|
||||
ev := entrySheetPart(eventName)
|
||||
if ev == "" {
|
||||
ev = "未命名小项"
|
||||
}
|
||||
cat := entrySheetPart(categoryName)
|
||||
if cat == "" {
|
||||
cat = "未命名组别"
|
||||
}
|
||||
|
||||
budget := entrySheetMaxUnits - unitsOf(entrySheetSeparator)
|
||||
// 给组别名留的额度不超过它自己的长度:组别名本来就短时,没必要白白砍掉小项名的字。
|
||||
reserve := entrySheetMinPartUnits
|
||||
if catUnits := unitsOf(cat); catUnits < reserve {
|
||||
reserve = catUnits
|
||||
}
|
||||
evKeep := unitsOf(ev)
|
||||
if room := budget - reserve; evKeep > room {
|
||||
evKeep = room
|
||||
}
|
||||
if evKeep < 1 {
|
||||
evKeep = 1
|
||||
}
|
||||
|
||||
ev = headWithinUnits(ev, evKeep)
|
||||
cat = headWithinUnits(cat, budget-unitsOf(ev))
|
||||
return ev + entrySheetSeparator + cat
|
||||
}
|
||||
|
||||
// entrySheetPart 归一化名字的一侧:去首尾空白,Excel 禁用字符换成同形全角字符。
|
||||
//
|
||||
// 换字符而不是删字符:删掉会改变名字的长相(「100 米/200 米」变成「100 米200 米」),
|
||||
// 现场对着表名找小项就找不到了;换成本表用的全角写法(与 export.sanitizeSheetName 同一张表),
|
||||
// 肉眼看上去还是原来那个名字。
|
||||
func entrySheetPart(raw string) string {
|
||||
name := strings.TrimSpace(raw)
|
||||
return strings.NewReplacer(
|
||||
"[", "(", "]", ")", ":", ":", "*", "*",
|
||||
"?", "?", "/", "/", "\\", "\",
|
||||
).Replace(name)
|
||||
}
|
||||
|
||||
// entrySheetKey 把工作表名归一化成可比对的键:全角 ASCII 转半角、丢掉 BOM、去掉全部空白、转小写。
|
||||
//
|
||||
// 生成端与解析端都要过它,两边算出来的键必然一致;现场手写的表名只要「字对得上」也能命中
|
||||
// (多打一个空格、输入法打成了全角字母都不算错)。
|
||||
func entrySheetKey(raw string) string {
|
||||
var builder strings.Builder
|
||||
builder.Grow(len(raw))
|
||||
for _, r := range raw {
|
||||
switch {
|
||||
case r == '\uFEFF':
|
||||
// BOM 粘在表名上是「表名对不上」的经典成因,直接丢掉。
|
||||
case r == '\u3000':
|
||||
// 全角空格与半角空格一样,比较时都忽略。
|
||||
case r >= '\uFF01' && r <= '\uFF5E':
|
||||
builder.WriteRune(r - 0xFEE0)
|
||||
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
|
||||
default:
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.ToLower(builder.String())
|
||||
}
|
||||
|
||||
// unitsOf 按 Excel 的口径数长度:UTF-16 编码单元数(汉字 1、emoji 2)。
|
||||
func unitsOf(raw string) int {
|
||||
units := 0
|
||||
for _, r := range raw {
|
||||
if r > 0xFFFF {
|
||||
units += 2
|
||||
continue
|
||||
}
|
||||
units++
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
// headWithinUnits 取前若干编码单元,且不切开代理对(emoji 只会整只丢)。
|
||||
func headWithinUnits(raw string, budget int) string {
|
||||
if budget <= 0 {
|
||||
return ""
|
||||
}
|
||||
var out []rune
|
||||
used := 0
|
||||
for _, r := range raw {
|
||||
size := 1
|
||||
if r > 0xFFFF {
|
||||
size = 2
|
||||
}
|
||||
if used+size > budget {
|
||||
break
|
||||
}
|
||||
used += size
|
||||
out = append(out, r)
|
||||
}
|
||||
if out == nil {
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// ---------- 导入:把上传的工作表定成待导入的表 ----------
|
||||
|
||||
// sameImportScopeName 比较表里的组别名与工作表名里的组别名:大小写、全角、首尾空白不算差异
|
||||
// (与表头识别同一把尺子),差一个字就是不一致。
|
||||
func sameImportScopeName(cell, scoped string) bool {
|
||||
return normalizeImportHeader(cell) == normalizeImportHeader(scoped)
|
||||
}
|
||||
|
||||
// withSheetPrefix 给工作表级的报错加上表名。单表口径的表名为空串,原样返回——老文案一字不改。
|
||||
//
|
||||
// 复制一份 apiError 而不是重建:status / code / detail / 拦截点都要保住,
|
||||
// 尤其是 origin——重建会让拦截日志指到本函数,现场拿着它找不到真正的校验代码。
|
||||
func withSheetPrefix(sheet string, err error) error {
|
||||
if sheet == "" {
|
||||
return err
|
||||
}
|
||||
var api *apiError
|
||||
if !errors.As(err, &api) {
|
||||
return err
|
||||
}
|
||||
clone := *api
|
||||
clone.message = fmt.Sprintf("[工作表 %s] %s", sheet, api.message)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// prepareImportTables 把上传的工作表定成「待导入的表」:分工作表口径下每张表都要能从工作表名
|
||||
// 定出 (小项, 组别)。
|
||||
//
|
||||
// 口径判定只看一件事:**有没有哪张工作表的名字里带 @**。
|
||||
// - 有 → 分工作表口径:每张表都必须能定出范围,定不出来的整份拒绝;
|
||||
// - 没有 → 单表口径:只导第一张可见表,其余工作表给一条提示(与升级前的行为一致,
|
||||
// 那时 xlsx 只读第一张,只是没说出口)。
|
||||
//
|
||||
// 为什么不「定不出来就跳过那张表」:跳过意味着用户填在第 3 张表里的几十行数据无声消失,
|
||||
// 而结果面板上的「新增 / 更新」看着还是成功的。宁可整份拒绝,让人当场知道要改哪张表。
|
||||
func (s *server) prepareImportTables(ctx context.Context, sheets []importSheet) ([]importTable, []string, error) {
|
||||
notices := make([]string, 0, 2)
|
||||
visible := make([]importSheet, 0, len(sheets))
|
||||
for _, sheet := range sheets {
|
||||
if sheet.hidden {
|
||||
notices = append(notices, fmt.Sprintf("工作表「%s」是隐藏表,按约定不参与导入", sheet.name))
|
||||
continue
|
||||
}
|
||||
visible = append(visible, sheet)
|
||||
}
|
||||
if len(visible) == 0 {
|
||||
return nil, notices, badRequest("报名表里没有可导入的工作表", nil)
|
||||
}
|
||||
if !hasEntrySheetName(visible) {
|
||||
// 单表口径:整份文件就是第一张表,范围由表内的「项目编码 / 组别」两列决定,
|
||||
// 解析结果与升级前完全一样。
|
||||
if len(visible) > 1 {
|
||||
ignored := make([]string, 0, len(visible)-1)
|
||||
for _, sheet := range visible[1:] {
|
||||
ignored = append(ignored, sheet.name)
|
||||
}
|
||||
notices = append(notices, fmt.Sprintf(
|
||||
"本工作簿有 %d 张工作表,按单表模板只导入了第一张「%s」,其余(%s)没有读。"+
|
||||
"如果这是一份「一个小项组别一张表」的模板,工作表名要写成「小项名称%s组别名称」(例如「100 米跑%s男子组」),"+
|
||||
"否则请把要导入的那张表放到第一张、或单独存成一个文件",
|
||||
len(visible), visible[0].name, strings.Join(ignored, "、"),
|
||||
entrySheetSeparator, entrySheetSeparator))
|
||||
}
|
||||
if len(visible[0].rows) < 2 {
|
||||
return nil, notices, badRequest("报名表为空", nil)
|
||||
}
|
||||
return []importTable{{rows: visible[0].rows}}, notices, nil
|
||||
}
|
||||
|
||||
scopes, err := s.loadEntrySheetScopes(ctx)
|
||||
if err != nil {
|
||||
return nil, notices, err
|
||||
}
|
||||
plan := newEntrySheetPlan(scopes)
|
||||
if len(plan.byKey) == 0 {
|
||||
return nil, notices, badRequest("这份表看起来是「分工作表模板」(有工作表名里带 "+entrySheetSeparator+"),"+
|
||||
"但当前赛事还没有任何「小项 + 组别」组合,没法把工作表名对回小项与组别。"+
|
||||
"请先到「基础数据 → 大项 / 小项」「组别」把基础数据配好再导入;"+
|
||||
"如果数据本来就都在一张表里,请改用单表模板(表内要有「项目编码」与「组别」两列)", nil)
|
||||
}
|
||||
|
||||
tables := make([]importTable, 0, len(visible))
|
||||
for _, sheet := range visible {
|
||||
scope, ok := plan.byKey[entrySheetKey(sheet.name)]
|
||||
if !ok {
|
||||
return nil, notices, badRequest(entrySheetMismatchMessage(sheet.name, plan), nil)
|
||||
}
|
||||
if len(sheet.rows) < 2 {
|
||||
// 只有表头(或者整张表是空的)= 这个小项组别这次没人报名:不报错,但要说一句。
|
||||
notices = append(notices, fmt.Sprintf("工作表「%s」没有数据行,已跳过", sheet.name))
|
||||
continue
|
||||
}
|
||||
tables = append(tables, importTable{sheet: sheet.name, rows: sheet.rows, scope: &scope})
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return nil, notices, badRequest("报名表为空:几张工作表都只有表头,没有要导入的数据行", nil)
|
||||
}
|
||||
return tables, notices, nil
|
||||
}
|
||||
|
||||
// hasEntrySheetName 判断这份上传是不是「分工作表模板」:只要有一张工作表名里带分隔符就算。
|
||||
func hasEntrySheetName(sheets []importSheet) bool {
|
||||
for _, sheet := range sheets {
|
||||
if strings.Contains(sheet.name, entrySheetSeparator) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// entrySheetMismatchMessage 是「工作表名对不上任何小项 + 组别」时的报错。
|
||||
//
|
||||
// 必须说清三件事:哪个表名、为什么对不上、怎么改。现场手上只有 Excel,只说「工作表名非法」
|
||||
// 等于让他猜——这个名字是自己手写的、还是被 Excel 改过、还是模板从别的赛事下载的,
|
||||
// 三种情况的改法完全不同。
|
||||
func entrySheetMismatchMessage(name string, plan entrySheetPlan) string {
|
||||
head := fmt.Sprintf("工作表「%s」没能定出是哪个小项、哪个组别:", name)
|
||||
if !strings.Contains(name, entrySheetSeparator) {
|
||||
return head + "工作表名里没有 " + entrySheetSeparator +
|
||||
"。分工作表模板的表名必须是「小项名称" + entrySheetSeparator + "组别名称」(例如「100 米跑" +
|
||||
entrySheetSeparator + "男子组」)。本工作簿里已经有一张表名带 " + entrySheetSeparator +
|
||||
" 了,整份文件按分工作表模板处理,每张表都要能定出小项与组别:" +
|
||||
"请把这张表删掉,或把它改名成「小项名称" + entrySheetSeparator + "组别名称」"
|
||||
}
|
||||
left, right, _ := strings.Cut(name, entrySheetSeparator)
|
||||
if _, ok := plan.eventByKey[entrySheetKey(left)]; !ok {
|
||||
return head + fmt.Sprintf("当前赛事里没有小项「%s」。请核对名字是否一字不差——"+
|
||||
"小项或组别改过名、名字太长被截断、模板不是从本赛事下载的,都会对不上;最稳的做法是重新下载分工作表模板", left)
|
||||
}
|
||||
return head + fmt.Sprintf("小项「%s」下没有组别「%s」。请核对组别名称,或重新下载分工作表模板", left, right)
|
||||
}
|
||||
|
||||
// entryTemplateHeader 是模板的表头:en 用英文规范名,zh 用中文首选别名。
|
||||
//
|
||||
// 与 download 的两个按钮、与 importColumns 逐列对应,也和单表 CSV 模板共用同一份列顺序——
|
||||
// 现场换模板不用重新数格子。改 importColumns 必须同步改这里(本函数就是从它算出来的)。
|
||||
func entryTemplateHeader(language string) []string {
|
||||
out := make([]string, 0, len(importColumns))
|
||||
for _, column := range importColumns {
|
||||
if language == "en" || len(column.aliases) == 0 {
|
||||
out = append(out, column.name)
|
||||
continue
|
||||
}
|
||||
out = append(out, column.aliases[0])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// entryTemplateFilename 是两种表头语言的下载文件名,前端按同一份口径拼文件名。
|
||||
func entryTemplateFilename(language string) string {
|
||||
if language == "en" {
|
||||
return "报名模板(分工作表·英文表头).xlsx"
|
||||
}
|
||||
return "报名模板(分工作表·中文表头).xlsx"
|
||||
}
|
||||
|
||||
// 模板里每列的展示宽度;没列的按 defaultEntryTemplateWidth 走。
|
||||
var entryTemplateWidths = map[string]float64{
|
||||
"event_code": 14,
|
||||
"category": 18,
|
||||
"team_code": 14,
|
||||
"athlete_name": 12,
|
||||
"id_card": 22,
|
||||
"gender": 8,
|
||||
"age": 8,
|
||||
"bib": 10,
|
||||
"seed": 12,
|
||||
"heat": 8,
|
||||
"status": 12,
|
||||
"member_names": 26,
|
||||
}
|
||||
|
||||
const defaultEntryTemplateWidth = 14
|
||||
|
||||
// entriesTemplateXLSX 生成「按小项 × 组别分工作表」的报名模板(GET /api/entries/template.xlsx?lang=zh|en)。
|
||||
//
|
||||
// 表头语言由 lang 决定(zh 缺省),与单表 CSV 模板的两个下载按钮是同一套口径:中文 / 英文各一份,
|
||||
// 列数与列顺序完全一样,只有表头语言不同。
|
||||
func (s *server) entriesTemplateXLSX(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
language := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("lang")))
|
||||
if language == "" {
|
||||
language = "zh"
|
||||
}
|
||||
if language != "zh" && language != "en" {
|
||||
return nil, badRequest("lang 只能是 zh 或 en(缺省 zh)", language)
|
||||
}
|
||||
|
||||
scopes, err := s.loadEntrySheetScopes(r.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan := newEntrySheetPlan(scopes)
|
||||
// 名字里带 @ 的历史数据没法写进工作表名:整次拒绝并点名,让人先改名。
|
||||
// 静默跳过会让现场拿到一份「少了几张表」的模板而毫不知情——那比报错危险得多。
|
||||
if len(plan.excluded) > 0 {
|
||||
return nil, conflict(entrySheetExcludedMessage(plan.excluded))
|
||||
}
|
||||
if len(plan.sheets) == 0 {
|
||||
return nil, badRequest("当前赛事还没有「小项 + 组别」的组合,分工作表模板没有可生成的表。"+
|
||||
"请先到「基础数据 → 大项 / 小项」建好小项,再到「组别」里给它配上组别", nil)
|
||||
}
|
||||
|
||||
headers := entryTemplateHeader(language)
|
||||
sheets := make([]entriesTemplateSheet, 0, len(plan.sheets))
|
||||
for _, planned := range plan.sheets {
|
||||
sheets = append(sheets, entriesTemplateSheet{Name: planned.Name, Headers: headers})
|
||||
}
|
||||
name := entryTemplateFilename(language)
|
||||
ascii := "entries-template-" + language + ".xlsx"
|
||||
return s.streamDownload(w, ascii, name, xlsxMIME, func(out io.Writer) error {
|
||||
return writeEntriesTemplateXLSX(out, sheets)
|
||||
})
|
||||
}
|
||||
|
||||
// entrySheetExcludedMessage 把「哪些名字挡住了模板生成」拼成能照着做的一句话。
|
||||
// 最多点 5 个名字,其余的报个数——几十个名字堆在提示里,现场反而看不到重点。
|
||||
func entrySheetExcludedMessage(problems []string) string {
|
||||
const show = 5
|
||||
head := problems
|
||||
tail := ""
|
||||
if len(head) > show {
|
||||
head = head[:show]
|
||||
tail = fmt.Sprintf("等 %d 处", len(problems))
|
||||
}
|
||||
return fmt.Sprintf("当前赛事有 %d 处小项 / 组别的名字里带 @(或全角 @),没法做成「小项名称@组别名称」的工作表名:%s%s。"+
|
||||
"请先到「基础数据 → 大项 / 小项」「组别」把它们改名(去掉 @),再重新下载模板;"+
|
||||
"改名不影响任何报名、成绩与积分", len(problems), strings.Join(head, ";"), tail)
|
||||
}
|
||||
|
||||
// entriesTemplateSheet 是一张待写的工作表。
|
||||
type entriesTemplateSheet struct {
|
||||
Name string
|
||||
Headers []string
|
||||
}
|
||||
|
||||
// writeEntriesTemplateXLSX 写整份模板工作簿。
|
||||
//
|
||||
// 版式与导出件刻意不同:**表头就在第 1 行**,没有标题行 / 副标题行。
|
||||
// 导入器认第 1 行为表头(见 importColumnIndex 与「表头上方不要加标题行」的现场约定),
|
||||
// 模板自己先摆两行标题,用户照着填回来的文件就会被判成「缺少列」。宁可朴素,也要能往返。
|
||||
func writeEntriesTemplateXLSX(out io.Writer, sheets []entriesTemplateSheet) error {
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
headerStyle, err := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Bold: true, Size: 11, Family: "微软雅黑", Color: "FFFFFF"},
|
||||
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"3482FF"}},
|
||||
Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center", WrapText: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("httpapi: 创建模板表头样式: %w", err)
|
||||
}
|
||||
|
||||
for i, sheet := range sheets {
|
||||
if i == 0 {
|
||||
if err := f.SetSheetName("Sheet1", sheet.Name); err != nil {
|
||||
return fmt.Errorf("httpapi: 重命名模板工作表 %s: %w", sheet.Name, err)
|
||||
}
|
||||
} else if _, err := f.NewSheet(sheet.Name); err != nil {
|
||||
return fmt.Errorf("httpapi: 新建模板工作表 %s: %w", sheet.Name, err)
|
||||
}
|
||||
for column, title := range sheet.Headers {
|
||||
cell, err := excelize.CoordinatesToCellName(column+1, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellValue(sheet.Name, cell, title); err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := excelize.ColumnNumberToName(column + 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
width := float64(defaultEntryTemplateWidth)
|
||||
if column < len(importColumns) {
|
||||
if custom, ok := entryTemplateWidths[importColumns[column].name]; ok {
|
||||
width = custom
|
||||
}
|
||||
}
|
||||
if err := f.SetColWidth(sheet.Name, name, name, width); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(sheet.Headers) > 0 {
|
||||
last, err := excelize.ColumnNumberToName(len(sheet.Headers))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.SetCellStyle(sheet.Name, "A1", last+"1", headerStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := f.SetRowHeight(sheet.Name, 1, 24); err != nil {
|
||||
return err
|
||||
}
|
||||
// 冻结表头:一张表几十行,往下翻的时候列名得一直看得见。
|
||||
if err := f.SetPanes(sheet.Name, &excelize.Panes{
|
||||
Freeze: true, YSplit: 1, TopLeftCell: "A2", ActivePane: "bottomLeft",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := f.Write(out); err != nil {
|
||||
return fmt.Errorf("httpapi: 写出模板: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 分工作表模板的命名规则:表名 = 小项名称@组别名称,且必须落进 Excel 的限制里
|
||||
// (最长 31 个 UTF-16 单元、不含 : \ / ? * [ ]、同一工作簿内不重名)。
|
||||
//
|
||||
// 这些用例是**纯函数**级别的:名字算错不会让程序崩,只会让现场拿到一份撞名的工作簿
|
||||
// (Excel 里后一张覆盖前一张,几十行数据无声消失)或一份解析不出来的模板,
|
||||
// 等到那时候再排查要翻好几层。所以边界在这里逐条钉死。
|
||||
|
||||
func TestEntrySheetNameKeepsBothParts(t *testing.T) {
|
||||
got := entrySheetName("100 米跑", "男子组 30岁以下")
|
||||
if got != "100 米跑@男子组 30岁以下" {
|
||||
t.Fatalf("表名应是「小项名称@组别名称」,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 超长:小项名优先保留头部,组别名至少留 8 字符,总长不越 31 单元。
|
||||
func TestEntrySheetNameTruncatesLongNames(t *testing.T) {
|
||||
eventName := "第三届全国职工运动会田径比赛男子甲组100米跑决赛" // 25 字
|
||||
category := "30岁以下公开组男子乙组" // 11 字
|
||||
got := entrySheetName(eventName, category)
|
||||
|
||||
if units := unitsOf(got); units > entrySheetMaxUnits {
|
||||
t.Fatalf("表名不得越过 %d 个 UTF-16 单元,得到 %d:%q", entrySheetMaxUnits, units, got)
|
||||
}
|
||||
left, right, ok := strings.Cut(got, entrySheetSeparator)
|
||||
if !ok {
|
||||
t.Fatalf("表名里必须有分隔符 %q:%q", entrySheetSeparator, got)
|
||||
}
|
||||
// 两侧都是原名的一段**前缀**(保留头部):解析端靠「重新算一遍映射」对上号,
|
||||
// 截断后仍是前缀,人工也能认出是哪一项。
|
||||
if !strings.HasPrefix(eventName, left) || !strings.HasPrefix(category, right) {
|
||||
t.Fatalf("截断必须保留头部:小项 %q / 组别 %q(原:%q / %q)", left, right, eventName, category)
|
||||
}
|
||||
if len([]rune(right)) < entrySheetMinPartUnits {
|
||||
t.Fatalf("组别名至少保留 %d 个字符,得到 %q", entrySheetMinPartUnits, right)
|
||||
}
|
||||
// 确定性:同样的输入任何时候都给出同一个名字。
|
||||
for i := 0; i < 5; i++ {
|
||||
if again := entrySheetName(eventName, category); again != got {
|
||||
t.Fatalf("同一份基础数据必须算出同一个表名:%q != %q", again, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组别名很短时不该白白砍掉小项名。
|
||||
func TestEntrySheetNameKeepsShortEventWhole(t *testing.T) {
|
||||
got := entrySheetName("100 米跑", "男")
|
||||
if got != "100 米跑@男" {
|
||||
t.Fatalf("组别名很短时不该截断小项名,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 含 emoji 的名字按 Excel 的 UTF-16 口径计数:一个 emoji 占 2 个单元。
|
||||
func TestEntrySheetNameCountsUTF16Units(t *testing.T) {
|
||||
got := entrySheetName(strings.Repeat("🏃", 20), strings.Repeat("组", 20))
|
||||
if units := unitsOf(got); units > entrySheetMaxUnits {
|
||||
t.Fatalf("emoji 也要按 UTF-16 单元算:%d > %d(%q)", units, entrySheetMaxUnits, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Excel 禁用字符必须换掉:留在表名里的后果是 excelize 直接拒绝建表、整份模板下载失败。
|
||||
func TestEntrySheetNameReplacesForbiddenChars(t *testing.T) {
|
||||
got := entrySheetName("100米/200米[甲]", "男子:组?")
|
||||
for _, forbidden := range []string{":", "\\", "/", "?", "*", "[", "]"} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("表名不应含 Excel 禁用字符 %q:%q", forbidden, got)
|
||||
}
|
||||
}
|
||||
if got != "100米/200米(甲)@男子:组?" {
|
||||
t.Fatalf("禁用字符应换成本表统一的全角写法,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 撞名去重:两个组合算出同一个表名时必须给出两个不同的名字,绝不互相覆盖。
|
||||
func TestEntrySheetPlanDedupesCollidingNames(t *testing.T) {
|
||||
long := strings.Repeat("男子甲组", 10) // 40 字,必然被截断
|
||||
scopes := []entrySheetScope{
|
||||
{EventID: 1, EventCode: "A", EventName: long + "一", CategoryID: 1, CategoryName: "公开组"},
|
||||
{EventID: 2, EventCode: "B", EventName: long + "二", CategoryID: 2, CategoryName: "公开组"},
|
||||
{EventID: 3, EventCode: "C", EventName: long + "三", CategoryID: 3, CategoryName: "公开组"},
|
||||
}
|
||||
plan := newEntrySheetPlan(scopes)
|
||||
if len(plan.sheets) != 3 {
|
||||
t.Fatalf("三个组合应出三张表,得到 %d:%+v", len(plan.sheets), plan.sheets)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, sheet := range plan.sheets {
|
||||
if unitsOf(sheet.Name) > entrySheetMaxUnits {
|
||||
t.Fatalf("去重后缀不能把名字顶出 31 单元:%q", sheet.Name)
|
||||
}
|
||||
if seen[sheet.Name] {
|
||||
t.Fatalf("两张工作表撞名(Excel 里会互相覆盖):%q", sheet.Name)
|
||||
}
|
||||
seen[sheet.Name] = true
|
||||
if _, ok := plan.byKey[entrySheetKey(sheet.Name)]; !ok {
|
||||
t.Fatalf("表名 %q 必须能在映射里查回来", sheet.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 半角 / 全角、空格、大小写不同不算两张表的区别:归一化后撞名同样要去重。
|
||||
func TestEntrySheetPlanDedupesNormalizedDuplicates(t *testing.T) {
|
||||
scopes := []entrySheetScope{
|
||||
{EventID: 1, EventCode: "A", EventName: "100 米跑", CategoryID: 1, CategoryName: "男子组"},
|
||||
{EventID: 2, EventCode: "B", EventName: "100 米跑", CategoryID: 2, CategoryName: "男子组"},
|
||||
}
|
||||
plan := newEntrySheetPlan(scopes)
|
||||
if len(plan.sheets) != 2 {
|
||||
t.Fatalf("应出两张表,得到 %d", len(plan.sheets))
|
||||
}
|
||||
if plan.sheets[0].Name == plan.sheets[1].Name {
|
||||
t.Fatalf("归一化后同名也算撞名,必须去重:%q", plan.sheets[0].Name)
|
||||
}
|
||||
if entrySheetKey(plan.sheets[1].Name) == entrySheetKey("100 米跑@男子组") {
|
||||
t.Fatalf("去重后的名字不该与第一张表的键相同:%q", plan.sheets[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// 同一对小项 + 组别只出一张表(重复数据不该多占一张表、挤掉别人的名字)。
|
||||
func TestEntrySheetPlanSkipsDuplicatePairs(t *testing.T) {
|
||||
scope := entrySheetScope{EventID: 7, EventCode: "M100", EventName: "100 米跑", CategoryID: 9, CategoryName: "男子组"}
|
||||
plan := newEntrySheetPlan([]entrySheetScope{scope, scope})
|
||||
if len(plan.sheets) != 1 {
|
||||
t.Fatalf("同一组合应只出一张表,得到 %d:%+v", len(plan.sheets), plan.sheets)
|
||||
}
|
||||
}
|
||||
|
||||
// 名字里含 @ 的组合没法写进工作表名:既不进映射,也要在 excluded 里点名(生成端据此拒绝下载)。
|
||||
func TestEntrySheetPlanExcludesAtSignNames(t *testing.T) {
|
||||
plan := newEntrySheetPlan([]entrySheetScope{
|
||||
{EventID: 1, EventCode: "A", EventName: "100@米", CategoryID: 1, CategoryName: "男子组"},
|
||||
{EventID: 2, EventCode: "B", EventName: "200 米", CategoryID: 2, CategoryName: "女子@组"},
|
||||
{EventID: 3, EventCode: "C", EventName: "400 米", CategoryID: 3, CategoryName: "混合组"},
|
||||
})
|
||||
if len(plan.sheets) != 1 || plan.sheets[0].Scope.EventID != 3 {
|
||||
t.Fatalf("只有合法的那一对能出表:%+v", plan.sheets)
|
||||
}
|
||||
if len(plan.excluded) != 2 {
|
||||
t.Fatalf("两处含 @ 的名字都要点名,得到 %+v", plan.excluded)
|
||||
}
|
||||
for _, msg := range plan.excluded {
|
||||
if !strings.Contains(msg, entrySheetSeparator) || !strings.Contains(msg, "分隔符") {
|
||||
t.Fatalf("文案要说清 @ 是模板里的分隔符,得到 %q", msg)
|
||||
}
|
||||
}
|
||||
if len(plan.byKey) != 1 {
|
||||
t.Fatalf("含 @ 的组合不该进映射(它根本表达不出来),得到 %d 项", len(plan.byKey))
|
||||
}
|
||||
}
|
||||
|
||||
// 空名字兜底:库里的名字理论上非空,但表名不能因此变成空的(excelize 会直接报错)。
|
||||
func TestEntrySheetNameFallsBackWhenBlank(t *testing.T) {
|
||||
got := entrySheetName(" ", "")
|
||||
if left, right, ok := strings.Cut(got, entrySheetSeparator); !ok || left == "" || right == "" {
|
||||
t.Fatalf("空名字也要给一个能用的表名,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 表名归一化:全角、空格、大小写、BOM 都不该让「同一张表」变成两张。
|
||||
func TestEntrySheetKeyNormalizes(t *testing.T) {
|
||||
want := entrySheetKey("100 米跑@男子组")
|
||||
for _, raw := range []string{
|
||||
"100 米跑@男子组",
|
||||
"100 米跑@男子组 ",
|
||||
"\uFEFF100米跑@男子组",
|
||||
"100米跑@男子组",
|
||||
"100 米 跑@男子组",
|
||||
} {
|
||||
if got := entrySheetKey(raw); got != want {
|
||||
t.Fatalf("entrySheetKey(%q) = %q,期望 %q", raw, got, want)
|
||||
}
|
||||
}
|
||||
// 大小写也算同一个键:列名识别就是这么宽容的,表名没有理由更严。
|
||||
if entrySheetKey("M100@男子组") != entrySheetKey("m100@男子组") {
|
||||
t.Fatal("表名比较应大小写不敏感")
|
||||
}
|
||||
}
|
||||
|
||||
// 生成端与解析端共用同一份规划:表名必须能原样查回自己的范围。
|
||||
func TestEntrySheetPlanRoundTrip(t *testing.T) {
|
||||
scopes := []entrySheetScope{
|
||||
{EventID: 1, EventCode: "M100", EventName: "100 米跑", CategoryID: 11, CategoryName: "男子组 30岁以下"},
|
||||
{EventID: 1, EventCode: "M100", EventName: "100 米跑", CategoryID: 12, CategoryName: "女子组"},
|
||||
{EventID: 2, EventCode: "BB", EventName: "多人抓杆蛇形跑", CategoryID: 21, CategoryName: "基层组"},
|
||||
}
|
||||
plan := newEntrySheetPlan(scopes)
|
||||
if len(plan.sheets) != 3 {
|
||||
t.Fatalf("三对组合应出三张表,得到 %d", len(plan.sheets))
|
||||
}
|
||||
for _, sheet := range plan.sheets {
|
||||
got, ok := plan.byKey[entrySheetKey(sheet.Name)]
|
||||
if !ok {
|
||||
t.Fatalf("表名 %q 查不回范围", sheet.Name)
|
||||
}
|
||||
if got != sheet.Scope {
|
||||
t.Fatalf("表名 %q 应查回 %+v,得到 %+v", sheet.Name, sheet.Scope, got)
|
||||
}
|
||||
}
|
||||
// 解析端还要能给出「找不到小项 / 找不到组别」的诊断。
|
||||
if _, ok := plan.eventByKey[entrySheetKey("100 米跑")]; !ok {
|
||||
t.Fatal("小项名索引里应能查到「100 米跑」")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// 分工作表报名模板:一个小项 × 一个组别一张工作表,表名「小项名称@组别名称」。
|
||||
//
|
||||
// 这里从 HTTP 层走完整条路:下载模板(服务端按当前赛事的基础数据建表)→ 往表里填数据 →
|
||||
// 原样传回来导入 → 查库。同时钉住两边都不能丢的东西:
|
||||
// - 单表 CSV / 单表 XLSX 的导入**一字不改照常工作**(老现场手里全是这种表);
|
||||
// - 工作表名与表内「项目编码 / 组别」两列冲突时报错,不猜、不静默取一个;
|
||||
// - 表名对不上时报错并点名是哪张表(绝不静默跳过,那是几十行数据无声消失)。
|
||||
|
||||
// uploadEntries 上传报名表(文件名决定按 CSV 还是 xlsx 解析)。
|
||||
func (e *testEnv) uploadEntries(t *testing.T, client *http.Client, filename string, data []byte) (int, envelope) {
|
||||
t.Helper()
|
||||
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
fw, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
t.Fatalf("创建上传字段: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
t.Fatalf("写上传内容: %v", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("关闭 multipart: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, e.server.URL+"/api/entries/import", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("构造请求: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
req.Header.Set("X-CSRF-Token", e.csrfToken(client))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("上传报名表: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
var env envelope
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if len(bytes.TrimSpace(raw)) > 0 {
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("解析响应: %v (%s)", err, raw)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, env
|
||||
}
|
||||
|
||||
// fetchTemplate 下载分工作表模板,返回工作簿与原始字节。
|
||||
func (e *testEnv) fetchTemplate(t *testing.T, client *http.Client, lang string) (*excelize.File, []byte) {
|
||||
t.Helper()
|
||||
|
||||
path := "/api/entries/template.xlsx"
|
||||
if lang != "" {
|
||||
path += "?lang=" + lang
|
||||
}
|
||||
resp, err := client.Get(e.server.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("下载模板: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("下载模板应 200,得到 %d:%s", resp.StatusCode, raw)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "spreadsheetml") {
|
||||
t.Fatalf("模板应是 xlsx,Content-Type 得到 %q", ct)
|
||||
}
|
||||
if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "entries-template-"+langOrZh(lang)+".xlsx") {
|
||||
t.Fatalf("下载文件名应带语言后缀,得到 %q", cd)
|
||||
}
|
||||
f, err := excelize.OpenReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("打开模板: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = f.Close() })
|
||||
return f, raw
|
||||
}
|
||||
|
||||
func langOrZh(lang string) string {
|
||||
if lang == "" {
|
||||
return "zh"
|
||||
}
|
||||
return lang
|
||||
}
|
||||
|
||||
// templateError 下载模板并返回错误文案(成功则直接失败)。
|
||||
func (e *testEnv) templateError(t *testing.T, client *http.Client, lang string) (int, string) {
|
||||
t.Helper()
|
||||
|
||||
resp, err := client.Get(e.server.URL + "/api/entries/template.xlsx?lang=" + lang)
|
||||
if err != nil {
|
||||
t.Fatalf("下载模板: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Fatalf("这次下载本应被拒绝,却回了 200(%d 字节)", len(raw))
|
||||
}
|
||||
var env envelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
t.Fatalf("解析错误响应: %v (%s)", err, raw)
|
||||
}
|
||||
if env.Error == nil {
|
||||
t.Fatalf("错误响应应带 error 体:%s", raw)
|
||||
}
|
||||
return resp.StatusCode, env.Error.Message
|
||||
}
|
||||
|
||||
// appendRows 往工作簿的某张表后面追加数据行(模板自带表头,这里只补数据)。
|
||||
func appendRows(t *testing.T, f *excelize.File, sheet string, rows [][]string) {
|
||||
t.Helper()
|
||||
|
||||
existing, err := f.GetRows(sheet)
|
||||
if err != nil {
|
||||
t.Fatalf("读取工作表 %s: %v", sheet, err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
for c, v := range row {
|
||||
cell, err := excelize.CoordinatesToCellName(c+1, len(existing)+i+1)
|
||||
if err != nil {
|
||||
t.Fatalf("算单元格坐标: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheet, cell, v); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func workbookBytes(t *testing.T, f *excelize.File) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := f.Write(&buf); err != nil {
|
||||
t.Fatalf("写出工作簿: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// seedTwoCategories 在种子上再补一个组别,凑出「一个小项 × 两个组别」。
|
||||
func seedTwoCategories(t *testing.T, env *testEnv, ids seedIDs) int64 {
|
||||
t.Helper()
|
||||
return env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
}
|
||||
|
||||
func TestEntriesTemplateOneSheetPerEventCategory(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedTwoCategories(t, env, ids)
|
||||
|
||||
f, _ := env.fetchTemplate(t, client, "")
|
||||
got := f.GetSheetList()
|
||||
want := []string{"100米跑@男子", "100米跑@女子"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("工作表数应等于「小项 × 组别」组合数 %d,得到 %d:%v", len(want), len(got), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("工作表名应为 %q,得到 %q(全部:%v)", want, got, got)
|
||||
}
|
||||
}
|
||||
|
||||
// 每张表都放同样的表头(中文表头),现场不用记哪张表列不一样。
|
||||
rows, err := f.GetRows(got[0])
|
||||
if err != nil {
|
||||
t.Fatalf("读取工作表: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("模板数据区应留空(范围由表名承担),得到 %d 行", len(rows))
|
||||
}
|
||||
wantHeader := strings.Join(entryTemplateHeaderForTest(t), ",")
|
||||
if gotHeader := strings.Join(rows[0], ","); gotHeader != wantHeader {
|
||||
t.Fatalf("表头应为中文列名\n得到 %s\n期望 %s", gotHeader, wantHeader)
|
||||
}
|
||||
}
|
||||
|
||||
// sheetImportHeader 是分工作表模板的完整 12 列表头(含批次列),与服务端模板逐列一致。
|
||||
// 既有测试用的 importHeader 少了 heat 列,这里不能复用它,否则每一列都会串位。
|
||||
const sheetImportHeader = "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names"
|
||||
|
||||
// entryTemplateHeaderForTest 是中文表头(与 importColumns 的中文别名、前端模板同源)。
|
||||
func entryTemplateHeaderForTest(t *testing.T) []string {
|
||||
t.Helper()
|
||||
return []string{
|
||||
"项目编码", "组别", "单位编码", "姓名", "证件号", "性别", "年龄",
|
||||
"号码布", "种子", "批次", "状态", "队员名单",
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntriesTemplateEnglishHeader(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedTwoCategories(t, env, ids)
|
||||
|
||||
f, _ := env.fetchTemplate(t, client, "en")
|
||||
rows, err := f.GetRows("100米跑@男子")
|
||||
if err != nil {
|
||||
t.Fatalf("读取工作表: %v", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("英文模板也要有表头行")
|
||||
}
|
||||
want := []string{
|
||||
"event_code", "category", "team_code", "athlete_name", "id_card", "gender", "age",
|
||||
"bib", "seed", "heat", "status", "member_names",
|
||||
}
|
||||
if !equalStrings(rows[0], want) {
|
||||
t.Fatalf("英文模板表头应为规范列名,得到 %v", rows[0])
|
||||
}
|
||||
// 非法语言要拦下,不能悄悄给一份中文模板。
|
||||
if status, msg := env.templateError(t, client, "fr"); status != http.StatusBadRequest {
|
||||
t.Fatalf("lang=fr 应 400,得到 %d(%s)", status, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func equalStrings(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 含 @ 的历史数据:生成端必须**拒绝并点名**,不能静默少几张表。
|
||||
func TestEntriesTemplateRejectsAtSignInNames(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子@甲组', 1, 1)`, ids.event)
|
||||
|
||||
status, msg := env.templateError(t, client, "zh")
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("名字里带 @ 应 409(先改名再下载),得到 %d", status)
|
||||
}
|
||||
// 文案要说清是哪一处、为什么、怎么办。
|
||||
if !strings.Contains(msg, "男子@甲组") || !strings.Contains(msg, "分隔符") {
|
||||
t.Fatalf("报错要点名并说清 @ 是分隔符,得到 %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "改名") {
|
||||
t.Fatalf("报错要给出下一步动作,得到 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 还没有基础数据时给一句能照着做的话,而不是一个空工作簿。
|
||||
func TestEntriesTemplateRequiresBaseData(t *testing.T) {
|
||||
env := newTestEnv(t)
|
||||
client := recorderClient(t, env)
|
||||
|
||||
status, msg := env.templateError(t, client, "zh")
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("没有小项组别应 400,得到 %d", status)
|
||||
}
|
||||
if !strings.Contains(msg, "组别") {
|
||||
t.Fatalf("报错要说明「先去建小项与组别」,得到 %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 超长名字:表名被截断但两侧仍可辨认,且必须落在 Excel 的 31 字符之内。
|
||||
func TestEntriesTemplateTruncatesLongSheetNames(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
longName := "第三届全国职工运动会田径比赛男子甲组100米跑决赛加时赛"
|
||||
env.exec(t, `UPDATE events SET name = ? WHERE id = ?`, longName, ids.event)
|
||||
env.exec(t, `UPDATE event_categories SET name = ? WHERE id = ?`, "30岁以下公开组男子乙组参赛队伍", ids.category)
|
||||
|
||||
f, _ := env.fetchTemplate(t, client, "zh")
|
||||
got := f.GetSheetList()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("应只有一张表,得到 %v", got)
|
||||
}
|
||||
name := got[0]
|
||||
if units := len([]rune(name)); units > 31 {
|
||||
t.Fatalf("表名不得超过 31 个字符:%q(%d)", name, units)
|
||||
}
|
||||
left, right, ok := strings.Cut(name, "@")
|
||||
if !ok || left == "" || right == "" {
|
||||
t.Fatalf("表名要保持「小项@组别」的形状:%q", name)
|
||||
}
|
||||
if !strings.HasPrefix(longName, left) {
|
||||
t.Fatalf("截断应保留小项名的开头(%q 不是 %q 的前缀)", left, longName)
|
||||
}
|
||||
}
|
||||
|
||||
// 撞名:两张表要拿到两个不同的名字,绝不能相互覆盖。
|
||||
func TestEntriesTemplateKeepsCollidingSheetsDistinct(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
longPrefix := "第三届全国职工运动会田径比赛男子甲组"
|
||||
env.exec(t, `UPDATE events SET name = ? WHERE id = ?`, longPrefix+"100米跑", ids.event)
|
||||
second := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', ?, 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport, longPrefix+"100米栏")
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, ?, 1, 0)`, second, "30岁以下公开组乙组")
|
||||
|
||||
f, _ := env.fetchTemplate(t, client, "zh")
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) != 2 {
|
||||
t.Fatalf("两个组合应出两张表,得到 %v", sheets)
|
||||
}
|
||||
if sheets[0] == sheets[1] {
|
||||
t.Fatalf("两张表撞名(Excel 会覆盖其中一张):%q", sheets[0])
|
||||
}
|
||||
for _, name := range sheets {
|
||||
if units := len([]rune(name)); units > 31 {
|
||||
t.Fatalf("去重后缀不能把表名顶出 31 字符:%q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:下载模板 → 往两张表填数据(项目编码与组别**留空**)→ 导入 → 查库。
|
||||
func TestImportSheetTemplateRoundTrip(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
secondCat := seedTwoCategories(t, env, ids)
|
||||
|
||||
f, _ := env.fetchTemplate(t, client, "")
|
||||
// 两张表各填一行:范围完全由表名承担,所以「项目编码 / 组别」两列留空。
|
||||
// 列顺序与模板一致:单位编码 / 姓名 / 证件号 / 性别 / 年龄 / 号码布 / 种子 / 批次 / 状态 / 队员名单。
|
||||
appendRows(t, f, "100米跑@男子", [][]string{{"", "", "A", "张三", "110101199001011234", "男", "26", "101", "", "1", "registered", ""}})
|
||||
appendRows(t, f, "100米跑@女子", [][]string{{"", "", "B", "李四", "110101199203031234", "女", "24", "202", "", "", "registered", ""}})
|
||||
env.exec(t, `UPDATE entries SET status = 'scratched' WHERE event_id = ? AND category_id = ?`, ids.event, ids.category)
|
||||
|
||||
status, resp := env.uploadEntries(t, client, "报名模板.xlsx", workbookBytes(t, f))
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("分工作表模板应导入成功,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 2 {
|
||||
t.Fatalf("应新建 2 条报名,得到 %+v", result)
|
||||
}
|
||||
if len(result.Errors) != 0 {
|
||||
t.Fatalf("不该有报错行:%v", result.Errors)
|
||||
}
|
||||
|
||||
// 男组:原本那条 scratched 报名被这一行改回 registered;女组:新落一条。
|
||||
for _, want := range []struct {
|
||||
categoryID int64
|
||||
name string
|
||||
}{
|
||||
{ids.category, "张三"},
|
||||
{secondCat, "李四"},
|
||||
} {
|
||||
var (
|
||||
got string
|
||||
status string
|
||||
)
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT a.name, e.status FROM entries e JOIN athletes a ON a.id = e.athlete_id
|
||||
WHERE e.event_id = ? AND e.category_id = ? AND a.name = ?`,
|
||||
ids.event, want.categoryID, want.name).Scan(&got, &status); err != nil {
|
||||
t.Fatalf("查库(组别 %d / %s): %v", want.categoryID, want.name, err)
|
||||
}
|
||||
if status != "registered" {
|
||||
t.Fatalf("组别 %d 的 %s 应正常参赛,得到 %s", want.categoryID, got, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// entryImportResultBody 是导入响应里我们关心的部分(与 entryImportResult 同构)。
|
||||
type entryImportResultBody struct {
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
Notices []string `json:"notices"`
|
||||
}
|
||||
|
||||
// 表名与表内列冲突:报错要点名是哪张表、两个值分别是什么,不许猜。
|
||||
func TestImportSheetTemplateRejectsColumnMismatch(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
second := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
|
||||
VALUES (?, '男子', 1, 0)`, second)
|
||||
|
||||
t.Run("项目编码与表名不符", func(t *testing.T) {
|
||||
data := buildTestXLSX(t, []testSheet{{name: "100米跑@男子", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"M200", "", "A", "张三", "110101199001011234", "男", "26", "101", "", "1", "registered", ""},
|
||||
}}})
|
||||
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("行级冲突应回 200 + 行级报错,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 0 || result.Skipped != 1 {
|
||||
t.Fatalf("冲突行不该落库:%+v", result)
|
||||
}
|
||||
if len(result.Errors) != 1 {
|
||||
t.Fatalf("应有一条行级报错:%v", result.Errors)
|
||||
}
|
||||
msg := result.Errors[0]
|
||||
for _, want := range []string{"100米跑@男子", "M200", "100米跑"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("报错要点名工作表与两个值(缺 %q):%s", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("组别与表名不符", func(t *testing.T) {
|
||||
data := buildTestXLSX(t, []testSheet{{name: "100米跑@男子", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"", "女子", "A", "张三", "110101199001011234", "男", "26", "101", "", "1", "registered", ""},
|
||||
}}})
|
||||
_, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if len(result.Errors) != 1 || !strings.Contains(result.Errors[0], "女子") ||
|
||||
!strings.Contains(result.Errors[0], "男子") {
|
||||
t.Fatalf("组别不符要点名两个值:%v", result.Errors)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 表名与表内列一致(照抄表名里的写法)时照常导入:现场从别的文件复制行过来不会被误拦。
|
||||
func TestImportSheetTemplateAcceptsMatchingColumns(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{{name: "100米跑@男子", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"M100", "男子", "A", "张三", "110101199001011234", "男", "26", "101", "", "1", "registered", ""},
|
||||
}}})
|
||||
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("应导入成功,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 || len(result.Errors) != 0 {
|
||||
t.Fatalf("应新建 1 条且无报错:%+v", result)
|
||||
}
|
||||
_ = ids
|
||||
}
|
||||
|
||||
// 表名对不上:整份拒绝并点名是哪张表(静默跳过会让几十行数据无声消失)。
|
||||
func TestImportSheetTemplateRejectsUnknownSheetName(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{
|
||||
{name: "100米跑@男子", rows: [][]string{strings.Split(sheetImportHeader, ",")}},
|
||||
{name: "备注", rows: [][]string{{"这张表是给填表人看的"}}},
|
||||
})
|
||||
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
if status != http.StatusBadRequest || resp.Error == nil {
|
||||
t.Fatalf("表名定不出范围应整份拒绝,得到 %d %+v", status, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "备注") || !strings.Contains(resp.Error.Message, "@") {
|
||||
t.Fatalf("报错要点名是哪张表、并说明表名该怎么写:%s", resp.Error.Message)
|
||||
}
|
||||
_ = ids
|
||||
}
|
||||
|
||||
// 表名里的组别在该小项下不存在:给一句「找不到小项 / 组别」的诊断。
|
||||
func TestImportSheetTemplateReportsMissingCategory(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{{name: "100米跑@少年组", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"", "", "A", "张三", "110101199001011234", "男", "26", "101", "", "1", "registered", ""},
|
||||
}}})
|
||||
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
if status != http.StatusBadRequest || resp.Error == nil {
|
||||
t.Fatalf("组别对不上应整份拒绝,得到 %d %+v", status, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "100米跑") || !strings.Contains(resp.Error.Message, "少年组") {
|
||||
t.Fatalf("报错要点名小项与组别:%s", resp.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// 表级问题(缺列)同样要点名是哪张表:几张表各有自己的「第 1 行」。
|
||||
func TestImportSheetTemplateNamesSheetInHeaderErrors(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{{name: "100米跑@男子", rows: [][]string{
|
||||
{"单位编码", "姓名"}, // 少了 team_code 之外的必填列
|
||||
{"A", "张三"},
|
||||
}}})
|
||||
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
if status != http.StatusBadRequest || resp.Error == nil {
|
||||
t.Fatalf("缺列应整份拒绝,得到 %d %+v", status, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Error.Message, "100米跑@男子") ||
|
||||
!strings.Contains(resp.Error.Message, "缺少列") {
|
||||
t.Fatalf("报错要点名工作表与缺的列:%s", resp.Error.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有表头的工作表 = 这个组别没人报名:跳过 + 提示,其余表照常导入。
|
||||
func TestImportSheetTemplateSkipsSheetsWithoutRows(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedTwoCategories(t, env, ids)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{
|
||||
{name: "100米跑@男子", rows: [][]string{strings.Split(sheetImportHeader, ",")}},
|
||||
{name: "100米跑@女子", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"", "", "B", "李四", "110101199203031234", "女", "24", "202", "", "", "registered", ""},
|
||||
}},
|
||||
})
|
||||
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("应导入成功,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 {
|
||||
t.Fatalf("应只导入有数据的那张表:%+v", result)
|
||||
}
|
||||
joined := strings.Join(result.Notices, ";")
|
||||
if !strings.Contains(joined, "100米跑@男子") || !strings.Contains(joined, "跳过") {
|
||||
t.Fatalf("空表要给一条提示:%v", result.Notices)
|
||||
}
|
||||
}
|
||||
|
||||
// 「项目编码 / 组别」两列整列不要也能导入:范围本来就由表名承担。
|
||||
func TestImportSheetTemplateAllowsMissingScopeColumns(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{{name: "100米跑@男子", rows: [][]string{
|
||||
{"team_code", "athlete_name", "bib", "seed", "status", "member_names"},
|
||||
{"A", "张三", "101", "", "registered", ""},
|
||||
}}})
|
||||
status, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("缺「项目编码 / 组别」两列应照常导入,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 {
|
||||
t.Fatalf("应新建 1 条:%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 跨工作表的重号同样是重号:号码布查重不能只在单张表内生效。
|
||||
func TestImportSheetTemplateDetectsBibDuplicateAcrossSheets(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
seedTwoCategories(t, env, ids)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{
|
||||
{name: "100米跑@男子", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"", "", "A", "张三", "110101199001011234", "男", "26", "101", "", "", "registered", ""},
|
||||
}},
|
||||
{name: "100米跑@女子", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"", "", "B", "李四", "110101199203031234", "女", "24", "101", "", "", "registered", ""},
|
||||
}},
|
||||
})
|
||||
_, resp := env.uploadEntries(t, client, "t.xlsx", data)
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 || result.Skipped != 1 {
|
||||
t.Fatalf("第二张表的同号行应被拦下:%+v", result)
|
||||
}
|
||||
if len(result.Errors) != 1 || !strings.Contains(result.Errors[0], "号码布") ||
|
||||
!strings.Contains(result.Errors[0], "100米跑@女子") {
|
||||
t.Fatalf("报错要带工作表名与原因:%v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:单表 XLSX(第一张表、表内有项目编码与组别)一字不改照常导入。
|
||||
func TestImportSingleTableXLSXStillWorks(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{{name: "Sheet1", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"M100", "男子", "A", "张三", "110101199001011234", "男", "26", "101", "", "1", "registered", ""},
|
||||
}}})
|
||||
status, resp := env.uploadEntries(t, client, "老表.xlsx", data)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("单表 xlsx 应照常导入,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 {
|
||||
t.Fatalf("应新建 1 条:%+v", result)
|
||||
}
|
||||
if len(result.Notices) != 0 {
|
||||
t.Fatalf("单张表不该有「其余工作表没读」的提示:%v", result.Notices)
|
||||
}
|
||||
}
|
||||
|
||||
// 多张表、名字都不像分工作表模板:仍按老口径只导第一张,但要**说出**其余没读。
|
||||
// 升级前是静默只读一张——「不许静默丢数据」这条就是为它立的。
|
||||
func TestImportMultiSheetWithoutAtSignUsesFirstSheetWithNotice(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
data := buildTestXLSX(t, []testSheet{
|
||||
{name: "报名表", rows: [][]string{
|
||||
strings.Split(sheetImportHeader, ","),
|
||||
{"M100", "男子", "A", "张三", "110101199001011234", "男", "26", "101", "", "1", "registered", ""},
|
||||
}},
|
||||
{name: "Sheet2", rows: [][]string{{"这份表里还有别的数据"}}},
|
||||
})
|
||||
status, resp := env.uploadEntries(t, client, "老表.xlsx", data)
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("应照常导入第一张表,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
result := decodeData[entryImportResultBody](t, resp)
|
||||
if result.Created != 1 {
|
||||
t.Fatalf("应导入第一张表的 1 条:%+v", result)
|
||||
}
|
||||
joined := strings.Join(result.Notices, ";")
|
||||
if !strings.Contains(joined, "Sheet2") || !strings.Contains(joined, "@") {
|
||||
t.Fatalf("要提示还有哪些工作表没读、以及分工作表模板的写法:%v", result.Notices)
|
||||
}
|
||||
}
|
||||
|
||||
// 多表 CSV 不存在;CSV 永远走单表口径(回归由既有的 CSV 用例覆盖,这里只钉一条最小的)。
|
||||
func TestImportSheetTemplateIgnoresCsvPath(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := "event_code,category,team_code,athlete_name,id_card,gender,age,bib,seed,heat,status,member_names\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,26,101,,1,registered,\n"
|
||||
status, resp := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !resp.OK {
|
||||
t.Fatalf("CSV 应照常导入,得到 %d %+v", status, resp.Error)
|
||||
}
|
||||
if result := decodeData[entryImportResultBody](t, resp); result.Created != 1 {
|
||||
t.Fatalf("应新建 1 条:%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
// 一个组别一个工作表:小项很多时表名也不会撞(表名按小项与组别逐个算,不靠序号)。
|
||||
func TestEntriesTemplateManyCombinations(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
for i := 1; i <= 5; i++ {
|
||||
eventID := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status, sort_order)
|
||||
VALUES (?, ?, ?, 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing', ?)`,
|
||||
ids.sport, fmt.Sprintf("E%d", i), fmt.Sprintf("%d00米跑", i), i)
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子组', 1, 0)`, eventID)
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子组', 1, 1)`, eventID)
|
||||
}
|
||||
f, _ := env.fetchTemplate(t, client, "zh")
|
||||
sheets := f.GetSheetList()
|
||||
// 种子:1 个小项 × 1 个组别;再加 5 个小项 × 2 个组别 → 共 11 张。
|
||||
if len(sheets) != 11 {
|
||||
t.Fatalf("11 个组合应出 11 张表,得到 %d:%v", len(sheets), sheets)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, name := range sheets {
|
||||
if seen[name] {
|
||||
t.Fatalf("工作表撞名:%q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
@@ -818,6 +818,11 @@ func (p *baseDataPlanner) planEvents(rows []export.BaseDataRow) {
|
||||
p.rowError(sheet, line, "小项名称必填")
|
||||
continue
|
||||
}
|
||||
// 基础数据包是第三条能写进小项名的路:与 POST /api/events 同口径,名字里不能有 @。
|
||||
if msg := nameSeparatorProblem("小项名称", name); msg != "" {
|
||||
p.rowError(sheet, line, "%s", msg)
|
||||
continue
|
||||
}
|
||||
if _, ok := p.sports[sportCode]; !ok {
|
||||
p.rowError(sheet, line, "大项编码 %s 不存在", sportCode)
|
||||
continue
|
||||
@@ -991,6 +996,11 @@ func (p *baseDataPlanner) planCategories(rows []export.BaseDataRow) {
|
||||
p.rowError(sheet, line, "组别名必填")
|
||||
continue
|
||||
}
|
||||
// 与 PUT /api/events/{id}/categories 同口径:组别名里不能有 @(分工作表模板的分隔符)。
|
||||
if msg := nameSeparatorProblem("组别名称", name); msg != "" {
|
||||
p.rowError(sheet, line, "%s", msg)
|
||||
continue
|
||||
}
|
||||
if _, ok := p.events[eventCode]; !ok {
|
||||
p.rowError(sheet, line, "小项编码 %s 不存在", eventCode)
|
||||
continue
|
||||
|
||||
@@ -762,8 +762,19 @@ const (
|
||||
// maxEntryImportBytes 是报名表上传的硬上限(16MB 文件 + multipart 开销)。
|
||||
const maxEntryImportBytes = 32 << 20
|
||||
|
||||
// readImportForm 解析报名表上传(multipart file 字段),返回表格行。
|
||||
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]string, error) {
|
||||
// importSheet 是解析后的一张待导入表。
|
||||
//
|
||||
// CSV 与「单表 xlsx」只有一张、名字为空串:这类文件的报错文案不加工作表前缀,
|
||||
// 与升级前逐字一致(现场手里几百份老表的报错格式不该因为这次改动变样)。
|
||||
type importSheet struct {
|
||||
name string
|
||||
rows [][]string
|
||||
// hidden 表示这是 xlsx 里的隐藏工作表:不导入,但要在结果里说一句(见 prepareImportTables)。
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// readImportForm 解析报名表上传(multipart file 字段),返回表格里的全部工作表。
|
||||
func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([]importSheet, error) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxEntryImportBytes)
|
||||
if err := r.ParseMultipartForm(16 << 20); err != nil {
|
||||
return nil, badRequest("上传文件解析失败", err.Error())
|
||||
@@ -773,16 +784,16 @@ func (s *server) readImportForm(w http.ResponseWriter, r *http.Request) ([][]str
|
||||
return nil, badRequest("缺少 file 字段", err.Error())
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
return readTable(filepath.Ext(header.Filename), file)
|
||||
return readTableSheets(filepath.Ext(header.Filename), file)
|
||||
}
|
||||
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns。
|
||||
// importEntriesHandler 解析 CSV/XLSX 报名表;表头见 importColumns,分工作表模板见 entries_sheet_template.go。
|
||||
func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
sheets, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.importEntries(r.Context(), rows)
|
||||
result, err := s.importEntriesWith(r.Context(), sheets, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -796,7 +807,7 @@ func (s *server) importEntriesHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
// resolveImportHandler 按用户对冲突的逐条决策重跑导入:
|
||||
// 已决策的冲突按决策落库,未决策的冲突继续留在 conflicts 里返回。
|
||||
func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||||
rows, err := s.readImportForm(w, r)
|
||||
sheets, err := s.readImportForm(w, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -819,7 +830,7 @@ func (s *server) resolveImportHandler(w http.ResponseWriter, r *http.Request) (a
|
||||
}
|
||||
byKey[key] = d.Action
|
||||
}
|
||||
result, err := s.importEntriesWith(r.Context(), rows, byKey)
|
||||
result, err := s.importEntriesWith(r.Context(), sheets, byKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -842,7 +853,11 @@ func (s *server) setEntriesHeatHandler(w http.ResponseWriter, r *http.Request) (
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
// readTableSheets 读出表格里的全部工作表(xlsx 按工作簿顺序;CSV 只有一张、名字为空串)。
|
||||
//
|
||||
// 单表 CSV / 单表 xlsx 的解析结果与升级前完全一样(同样的一张表、同一套表头口径),
|
||||
// 变化只在 xlsx 多读了几张表——分工作表模板要用得到,见 prepareImportTables。
|
||||
func readTableSheets(ext string, file io.Reader) ([]importSheet, error) {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".xlsx":
|
||||
f, err := excelize.OpenReader(file)
|
||||
@@ -850,15 +865,30 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
return nil, badRequest("无法解析 xlsx", err.Error())
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
sheets := f.GetSheetList()
|
||||
if len(sheets) == 0 {
|
||||
names := f.GetSheetList()
|
||||
if len(names) == 0 {
|
||||
return nil, badRequest("xlsx 没有工作表", nil)
|
||||
}
|
||||
rows, err := f.GetRows(sheets[0])
|
||||
if err != nil {
|
||||
return nil, badRequest("读取 xlsx 失败", err.Error())
|
||||
out := make([]importSheet, 0, len(names))
|
||||
hidden := 0
|
||||
for _, name := range names {
|
||||
// 隐藏工作表按约定不是给人填的数据(工具生成的辅助表常见),不参与导入;
|
||||
// 但要在结果里说一句,免得「有一张表没导进去」变成静默丢数据。
|
||||
sheetHidden := false
|
||||
if visible, err := f.GetSheetVisible(name); err == nil && !visible {
|
||||
sheetHidden = true
|
||||
hidden++
|
||||
}
|
||||
rows, err := f.GetRows(name)
|
||||
if err != nil {
|
||||
return nil, badRequest("读取 xlsx 失败", err.Error())
|
||||
}
|
||||
out = append(out, importSheet{name: name, rows: rows, hidden: sheetHidden})
|
||||
}
|
||||
return rows, nil
|
||||
if hidden == len(out) {
|
||||
return nil, badRequest("xlsx 里的工作表都是隐藏的,没有可导入的数据", nil)
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
text, err := decodeTableText(file)
|
||||
if err != nil {
|
||||
@@ -873,7 +903,7 @@ func readTable(ext string, file io.Reader) ([][]string, error) {
|
||||
// Go 的英文原文(parse error on line ...)留在 detail 里,排查时还要用。
|
||||
return nil, badRequest("无法解析 CSV:表格里多半有没配对的双引号。请用 Excel 打开原文件,另存为「CSV UTF-8」后再导入", err.Error())
|
||||
}
|
||||
return rows, nil
|
||||
return []importSheet{{rows: rows}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,40 +1132,52 @@ func importColumnIndex(header []string) (map[string]int, error) {
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func (s *server) importEntries(ctx context.Context, rows [][]string) (entryImportResult, error) {
|
||||
return s.importEntriesWith(ctx, rows, nil)
|
||||
// importTable 是一张进入导入流程的表。
|
||||
//
|
||||
// scope 为 nil 表示单表口径:范围由表内的「项目编码 / 组别」两列决定(CSV 与单表 xlsx 都走这条)。
|
||||
// scope 非 nil 表示分工作表口径:范围由工作表名给定,表内那两列可以留空,填了就必须与工作表名一致。
|
||||
type importTable struct {
|
||||
sheet string
|
||||
rows [][]string
|
||||
scope *entrySheetScope
|
||||
}
|
||||
|
||||
// bibOwner 汇总一个号码布在一份报名表里被哪些行用过,用来区分「同一个人报多个项目」与
|
||||
// 「两个人在抢同一个号码布」。
|
||||
//
|
||||
// 判据是证件号:号码布是现场认人的依据,但它同时是一人多项目的合法重复值,只有证件号能证明
|
||||
// 这是两个不同的人(两边都填了且对不上)。证件号相同、或有一方没填——后者是现场的常态,
|
||||
// 证件号列常整列留空——都算同一个人,只在收尾用 notices 提示,绝不拦下这些行。
|
||||
type bibOwner struct {
|
||||
idCard string // 第一次见到的非空证件号;空表示这些行都没填
|
||||
team bool // 这个号码布落在团体行上(团体行没有「人」,重复一律报错)
|
||||
rows int // 用过这个号码布、且已放行的行数
|
||||
scope entryScope // 第一行落在哪个 (小项, 组别)
|
||||
scopes map[entryScope]bool // 第二行起记放行行落到过哪些 (小项, 组别)
|
||||
unproven bool // 放行靠的是「证件号没填齐」,而不是证件号对得上
|
||||
repeatScope bool // 有第二行落在同一个 (小项, 组别) 上 = 同一人同一组别重复报名
|
||||
}
|
||||
|
||||
// importEntriesWith 是导入主体。decisions 为 nil 表示纯导入:冲突只收集不落库;
|
||||
// 非 nil 时按决策处理命中人员的冲突,未出现在 decisions 里的冲突继续收集。
|
||||
func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisions map[string]string) (entryImportResult, error) {
|
||||
//
|
||||
// sheets 是上传文件里的全部工作表:CSV 与单表 xlsx 只有一张(名字为空),分工作表模板则一张表一个
|
||||
// (小项, 组别)。两种口径在这份代码里往下走同一条路,于是人员匹配、号码布查重、跨行去重这些判据
|
||||
// (bibOwners / idCardCounts / seenIdentities)天然是**跨工作表**的:同一个号码布出现在两张表里,
|
||||
// 照样按「报名表内重号」处理。
|
||||
func (s *server) importEntriesWith(ctx context.Context, sheets []importSheet, decisions map[string]string) (entryImportResult, error) {
|
||||
result := entryImportResult{
|
||||
Errors: []string{}, Notices: []string{}, Conflicts: []importConflict{}, SameNameNewPersons: []sameNameNewPerson{},
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
return result, badRequest("报名表为空", nil)
|
||||
}
|
||||
index, err := importColumnIndex(rows[0])
|
||||
tables, notices, err := s.prepareImportTables(ctx, sheets)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for _, column := range importColumns {
|
||||
if column.optional {
|
||||
continue
|
||||
}
|
||||
if _, ok := index[column.name]; !ok {
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
// 列名中英并列:手上是中文模板的人一眼对得上,不会以为系统只认英文。
|
||||
return result, badRequest("报名表缺少列 "+column.label()+
|
||||
":第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil)
|
||||
}
|
||||
}
|
||||
result.Notices = append(result.Notices, notices...)
|
||||
|
||||
// 号码布不再是人员的唯一标识:同一个人报多个组别时可以重复使用,只有「两个不同的人共用一个号码布」
|
||||
// 才按行报错,所以这里记的是号码布 -> 第一次出现的那个人的证件号。
|
||||
// 空串有专门含义:登记者没有证件号(个人行没填,或本来就是团体行),复用它一律按重号处理。
|
||||
bibOwners := make(map[string]string)
|
||||
// 号码布是现场认定的「人」:同一个人报多个项目时它会重复出现。判重只认证件号能不能
|
||||
// 证明这是两个人(见 bibOwner),所以这里记的是号码布 -> 用过它的那些行。
|
||||
bibOwners := make(map[string]bibOwner)
|
||||
// 证件号才是唯一标识:同一份表里重复出现 = 同一个人报多个组别(不报错,收尾统一给 notices 提示);
|
||||
// 但「同一证件号 + 同一个小项同一个组别」是真重复,只有它进冲突面板等决策。
|
||||
idCardCounts := make(map[string]int)
|
||||
@@ -1144,188 +1186,283 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
// 名单里的姓名 / 号码布 / 批次这类不影响算分的字段改动不该触发重算,
|
||||
// 否则几百行的常规导入会把每个组白算一遍(见 recomputeImportedScopes)。
|
||||
affectedScopes := make([]entryScope, 0, 2)
|
||||
for n, row := range rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
eventCode := cell("event_code")
|
||||
categoryName := cell("category")
|
||||
teamCode := cell("team_code")
|
||||
athleteName := cell("athlete_name")
|
||||
if eventCode == "" || teamCode == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
|
||||
continue
|
||||
}
|
||||
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
|
||||
status, err := normalizeImportStatus(cell("status"))
|
||||
for _, table := range tables {
|
||||
index, err := importColumnIndex(table.rows[0])
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
return result, withSheetPrefix(table.sheet, err)
|
||||
}
|
||||
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
|
||||
heat, ok := parseImportHeat(cell("heat"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line,
|
||||
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
|
||||
continue
|
||||
for _, column := range importColumns {
|
||||
if column.optional {
|
||||
continue
|
||||
}
|
||||
// 分工作表口径下范围由工作表名承担:「项目编码 / 组别」两列可以整列不要。
|
||||
if table.scope != nil && (column.name == "event_code" || column.name == "category") {
|
||||
continue
|
||||
}
|
||||
if _, ok := index[column.name]; !ok {
|
||||
// 表头类报错必须点到「表头行」本身:用户常在表头上方加一行标题,
|
||||
// 只说「缺少列 X」会让他去翻数据行,找不到问题在哪。
|
||||
// 列名中英并列:手上是中文模板的人一眼对得上,不会以为系统只认英文。
|
||||
return result, withSheetPrefix(table.sheet, badRequest("报名表缺少列 "+column.label()+
|
||||
":第 1 行应是表头(列名与导入模板一致,中文与英文表头都认),请确认没有在表头上方加标题行、也没有把表头写成别的名字", nil))
|
||||
}
|
||||
}
|
||||
|
||||
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
|
||||
continue
|
||||
// 行级报错在工作表口径下带上表名:几张表各有自己的「第 3 行」,只说行号现场认不出是哪张表。
|
||||
// 单表 / CSV 的 prefix 是空串,报错文案与升级前逐字一致。
|
||||
prefix := ""
|
||||
if table.sheet != "" {
|
||||
prefix = "[工作表 " + table.sheet + "] "
|
||||
}
|
||||
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
|
||||
continue
|
||||
}
|
||||
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
|
||||
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
|
||||
if err := s.reviveTeam(ctx, teamID); err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
var categoryID int64
|
||||
if categoryName != "" {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||||
|
||||
for n, row := range table.rows[1:] {
|
||||
line := n + 2
|
||||
cell := func(name string) string {
|
||||
i, ok := index[name]
|
||||
if !ok || i >= len(row) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(row[i])
|
||||
}
|
||||
eventCode := cell("event_code")
|
||||
categoryName := cell("category")
|
||||
teamCode := cell("team_code")
|
||||
athleteName := cell("athlete_name")
|
||||
if teamCode == "" || (table.scope == nil && eventCode == "") {
|
||||
result.Skipped++
|
||||
if table.scope == nil {
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "event_code / team_code 必填"))
|
||||
} else {
|
||||
// 分工作表口径下小项由工作表名给定,缺的只可能是单位编码。
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "team_code(单位编码)必填"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
|
||||
status, err := normalizeImportStatus(cell("status"))
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份,
|
||||
// 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。
|
||||
// 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。
|
||||
idCard := cell("id_card")
|
||||
bib := cell("bib")
|
||||
if bib != "" {
|
||||
// 同一个人(证件号相同且都填了)可以共用号码布;其余重复一律按行报错。
|
||||
// 团体行没有「同一个人」这回事(它没有证件号可言),因此一律按重号处理。
|
||||
owner := idCard
|
||||
if athleteName == "" {
|
||||
owner = ""
|
||||
}
|
||||
if prev, seen := bibOwners[bib]; seen && (owner == "" || prev != owner) {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
}
|
||||
bibOwners[bib] = owner
|
||||
}
|
||||
|
||||
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
|
||||
if idCard == "" && bib == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
||||
continue
|
||||
}
|
||||
age, ok := parseImportAge(cell("age"))
|
||||
// heat 是可选列:留空 = 未分批;填了必须是正整数,否则整行拦截(其余行照常导入)。
|
||||
heat, ok := parseImportHeat(cell("heat"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
|
||||
"heat 需要是 1-%d 的整数(留空表示未分批)", maxEntryHeat))
|
||||
continue
|
||||
}
|
||||
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
|
||||
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
|
||||
duplicate := false
|
||||
if idCard != "" {
|
||||
identity := fmt.Sprintf("%s\x00%d\x00%d", idCard, eventID, categoryID)
|
||||
duplicate = seenIdentities[identity]
|
||||
seenIdentities[identity] = true
|
||||
idCardCounts[idCard]++
|
||||
}
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Duplicate: duplicate,
|
||||
}, decisions)
|
||||
// 范围:单表口径按表里的两列查;分工作表口径由工作表名给定,表里填了就必须与它一致。
|
||||
// 不一致时**报错而不是挑一个用**:两处说法矛盾时系统无从判断该信哪个,
|
||||
// 静默取工作表名会让填错的行悄悄落到别的小项上去。
|
||||
var eventID, categoryID int64
|
||||
if table.scope != nil {
|
||||
eventID, categoryID = table.scope.EventID, table.scope.CategoryID
|
||||
if eventCode != "" {
|
||||
scoped, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 不存在", eventCode))
|
||||
continue
|
||||
}
|
||||
if scoped != eventID {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
|
||||
"本行项目编码 %s 不是工作表名里那个小项「%s」:分工作表模板里这两处必须说的是同一个小项。"+
|
||||
"请删掉这一格(留空即按工作表名走),或改成本工作表对应的小项编码",
|
||||
eventCode, table.scope.EventName))
|
||||
continue
|
||||
}
|
||||
}
|
||||
if categoryName != "" && !sameImportScopeName(categoryName, table.scope.CategoryName) {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line,
|
||||
"本行组别「%s」与工作表名里的组别「%s」不一致:分工作表模板里这两处必须是同一个组别。"+
|
||||
"请删掉这一格(留空即按工作表名走),或改成工作表名里的组别", categoryName, table.scope.CategoryName))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
eventID, err = s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 不存在", eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", importRowMessage(err)))
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "单位 %s 不存在", teamCode))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
if matched.Skip {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
|
||||
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
|
||||
if err := s.reviveTeam(ctx, teamID); err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
|
||||
continue
|
||||
}
|
||||
if matched.SameNameNewPerson != nil {
|
||||
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||
if table.scope == nil {
|
||||
if categoryName != "" {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "组别 %s 不存在", categoryName))
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "小项 %s 没有组别", eventCode))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
athleteID = &matched.AthleteID
|
||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
TeamID: teamID,
|
||||
AthleteID: athleteID,
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
Bib: nilIfEmpty(bib),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Heat: heat,
|
||||
Status: status,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
|
||||
case outcome.Created:
|
||||
result.Created++
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
|
||||
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
|
||||
for _, moved := range outcome.MovedEntries {
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入",
|
||||
line, moved.Name, moved.OldTeamCode, moved.NewTeamCode))
|
||||
// 号码布查重对**所有行**生效(含团体行):团体行没有人员,但号码布照样是这张表的身份,
|
||||
// 同一张表里两个团体行、或团体行与个人行共用一个号码布都必须报出来。
|
||||
// 这段原先写在 athleteName != "" 里面,团体行填的号码布既不登记也不查重,重号会静默落库。
|
||||
//
|
||||
// 个人行之间的重复**只按证件号判**:证件号是人员的唯一标识,号码布不是——同一个人报多个
|
||||
// 项目本来就会带着同一个号码布出现好几行。两边证件号都填了且对不上才算「两个人抢一个号」;
|
||||
// 证件号相同、或有一方没填(现场常不填),都证明不了是两个人,一律当同一个人放行,
|
||||
// 收尾用 notices 说清楚这个号码布出现在几行上,让操作员自己核对。
|
||||
idCard := cell("id_card")
|
||||
bib := cell("bib")
|
||||
if bib != "" {
|
||||
owner, seen := bibOwners[bib]
|
||||
switch {
|
||||
case seen && (owner.team || athleteName == ""):
|
||||
// 团体行没有「人」可言,谈不上「视为同一个人」,一律按重号报错(升级前的行为)。
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
case seen && owner.idCard != "" && idCard != "" && owner.idCard != idCard:
|
||||
// 两个人共用一个号码布:保持升级前的行级报错口径。
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
|
||||
continue
|
||||
}
|
||||
if seen {
|
||||
// 复用放行:记下这次靠的是「证件号对得上」还是「有一方没填、证明不了」,
|
||||
// 以及第二行起落在哪些 (小项, 组别) 上——同一组别上出现两次是真重复报名。
|
||||
scope := entryScope{eventID, categoryID}
|
||||
if owner.scopes == nil {
|
||||
owner.scopes = map[entryScope]bool{owner.scope: true}
|
||||
}
|
||||
if owner.scopes[scope] {
|
||||
owner.repeatScope = true
|
||||
}
|
||||
owner.scopes[scope] = true
|
||||
owner.unproven = owner.unproven || owner.idCard == "" || idCard == ""
|
||||
} else {
|
||||
owner.scope = entryScope{eventID, categoryID}
|
||||
owner.team = athleteName == ""
|
||||
}
|
||||
if owner.idCard == "" {
|
||||
owner.idCard = idCard
|
||||
}
|
||||
owner.rows++
|
||||
bibOwners[bib] = owner
|
||||
}
|
||||
default:
|
||||
result.Updated++
|
||||
if outcome.StatusChanged {
|
||||
|
||||
// 团体/对抗行(athlete_name 为空)行为不变:只建报名,不涉及人员。
|
||||
var athleteID *int64
|
||||
if athleteName != "" {
|
||||
// 有姓名就必须给证件号或号码布之一:两者都空时无法确认是哪一个人。
|
||||
if idCard == "" && bib == "" {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
|
||||
continue
|
||||
}
|
||||
age, ok := parseImportAge(cell("age"))
|
||||
if !ok {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "age 需要是 0-120 的整数"))
|
||||
continue
|
||||
}
|
||||
// 真重复判据与服务端认人顺序一致:同一证件号 + 同一个小项同一个组别。
|
||||
// 组别留空时上面已取该小项的第一个组别,这里用解析出的 categoryID 比。
|
||||
duplicate := false
|
||||
if idCard != "" {
|
||||
identity := fmt.Sprintf("%s\x00%d\x00%d", idCard, eventID, categoryID)
|
||||
duplicate = seenIdentities[identity]
|
||||
seenIdentities[identity] = true
|
||||
idCardCounts[idCard]++
|
||||
}
|
||||
|
||||
matched, err := s.matchImportedAthlete(ctx, athleteImportRow{
|
||||
Line: line,
|
||||
Name: athleteName,
|
||||
Gender: normalizeGender(cell("gender")),
|
||||
TeamID: teamID,
|
||||
TeamCode: teamCode,
|
||||
IDCard: idCard,
|
||||
Bib: bib,
|
||||
Age: age,
|
||||
Duplicate: duplicate,
|
||||
}, decisions)
|
||||
if err != nil {
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", importRowMessage(err)))
|
||||
continue
|
||||
}
|
||||
if matched.Conflict != nil {
|
||||
result.Conflicts = append(result.Conflicts, *matched.Conflict)
|
||||
continue
|
||||
}
|
||||
if matched.Skip {
|
||||
// 真重复行 + 用户选了「保留已有」:库里那条报名原样不动,本行不落库。
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if matched.SameNameNewPerson != nil {
|
||||
result.SameNameNewPersons = append(result.SameNameNewPersons, *matched.SameNameNewPerson)
|
||||
}
|
||||
athleteID = &matched.AthleteID
|
||||
// keep-existing 时报名跟随库里已有的单位,避免报名与人员的单位不一致。
|
||||
teamID = matched.TeamID
|
||||
}
|
||||
|
||||
outcome, err := s.upsertImportedEntry(ctx, entryDTO{
|
||||
EventID: eventID,
|
||||
CategoryID: categoryID,
|
||||
TeamID: teamID,
|
||||
AthleteID: athleteID,
|
||||
MemberNames: splitNames(cell("member_names")),
|
||||
Bib: nilIfEmpty(bib),
|
||||
Seed: nilIfEmpty(cell("seed")),
|
||||
Heat: heat,
|
||||
Status: status,
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
result.Skipped++
|
||||
result.Errors = append(result.Errors, prefix+s.interceptRow(line, "%s", err.Error()))
|
||||
case outcome.Created:
|
||||
result.Created++
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
// 换单位的人:旧单位那条过期报名已被移出。数据口径是对的,但不说出来操作员
|
||||
// 只会看到「旧单位的报名不见了」——用既有 notices[] 通道讲清楚是谁、从哪到哪(清单 A10)。
|
||||
for _, moved := range outcome.MovedEntries {
|
||||
result.Notices = append(result.Notices, prefix+fmt.Sprintf(
|
||||
"第 %d 行:%s 换了单位,原单位 %s 在本组别的报名已移出,已改在单位 %s 加入",
|
||||
line, moved.Name, moved.OldTeamCode, moved.NewTeamCode))
|
||||
}
|
||||
default:
|
||||
result.Updated++
|
||||
if outcome.StatusChanged {
|
||||
affectedScopes = addEntryScope(affectedScopes, outcome.EventID, outcome.CategoryID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 同一证件号在本表里出现多次 = 同一个人报多个组别:不报错,但要让现场看见。
|
||||
// 按证件号排序,保证同一份表的提示顺序稳定(前端展示与用例断言都依赖它)。
|
||||
// 分工作表模板下这条判据是**跨表**的:同一个人在两个组别各一行,同样只给一条提示。
|
||||
idCards := make([]string, 0, len(idCardCounts))
|
||||
for idCard := range idCardCounts {
|
||||
idCards = append(idCards, idCard)
|
||||
@@ -1337,6 +1474,29 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
|
||||
fmt.Sprintf("证件号 %s 在本表中出现 %d 次(同一人报名多个组别)", idCard, count))
|
||||
}
|
||||
}
|
||||
// 号码布重复出现、但证件号证明不了是两个人(有一方或双方没填)时,行放行但要说清楚:
|
||||
// 号码布是现场认人的依据,重复既可能是一人多项目,也可能是表格填错。
|
||||
// 与证件号那条同一个 notices 通道,也按号码布排序,保证同一份表的提示顺序稳定。
|
||||
bibs := make([]string, 0, len(bibOwners))
|
||||
for bib := range bibOwners {
|
||||
bibs = append(bibs, bib)
|
||||
}
|
||||
sort.Strings(bibs)
|
||||
for _, bib := range bibs {
|
||||
owner := bibOwners[bib]
|
||||
if owner.rows < 2 || !owner.unproven {
|
||||
// 证件号对得上(两边都填了同一个号)时不用这条:证件号那条提示已经说过这件事了。
|
||||
continue
|
||||
}
|
||||
if owner.repeatScope {
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"号码布 %s 在本表中出现 %d 次,其中同一小项同一组别有重复行:同一个人的同一组别只保留一条报名(后一行覆盖前一行),请核对表格",
|
||||
bib, owner.rows))
|
||||
continue
|
||||
}
|
||||
result.Notices = append(result.Notices, fmt.Sprintf(
|
||||
"号码布 %s 在本表中出现 %d 次,证件号没填齐,已视为同一人报名多个项目", bib, owner.rows))
|
||||
}
|
||||
// 导入动过「谁还算数」的组,按当前报名与成绩重算名次与流水并推榜。
|
||||
// 与批量退赛/移出、作废成绩同一口径:延迟结算下也要当场算,不然名单改了榜不动。
|
||||
if err := s.recomputeImportedScopes(ctx, affectedScopes, middleware.User(ctx).ID); err != nil {
|
||||
|
||||
@@ -801,13 +801,14 @@ func TestImportBackfillsIDCardWhenMatchedByBib(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 同一文件里两个不同的人共用一个号码布 → 该行报错并跳过;同一个人报多个组别时号码布可以重复。
|
||||
// 同一文件里两个不同的人(证件号都填了且对不上)共用一个号码布 → 该行报错并跳过。
|
||||
// 号码布是现场认人的依据,但它同时是一人多项目的合法重复值,只有证件号能证明这是两个人。
|
||||
func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,,B2001,,registered,\n" +
|
||||
"M100,男子,A,李四,,男,,B2001,,registered,\n"
|
||||
"M100,男子,A,张三,110101199001011234,男,,B2001,,registered,\n" +
|
||||
"M100,男子,A,李四,110101199002021234,男,,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
@@ -830,6 +831,162 @@ func TestImportRejectsDuplicateBibInFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布相同、两行都没填证件号、分属不同小项 = 同一个人报多个项目。
|
||||
// 证件号是可选列、现场常整列留空,这里**不能**按「号码布重复」拦下整块表:
|
||||
// 两行都落库、只建一个人员,另给一条提示说明号码布出现两次。
|
||||
func TestImportRepeatedBibWithoutIDCardIsOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
secondEvent := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'M200', '200米跑', 'individual', 'auto', 'time', 'asc', 1, 1, 'ongoing')`, ids.sport)
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, secondEvent)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,26,B2001,,registered,\n" +
|
||||
"M200,男子,A,张三,,男,26,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同一个人两个小项都应落库: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("号码布相同但证件号都没填,不该报重号: %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
want := "号码布 B2001 在本表中出现 2 次,证件号没填齐,已视为同一人报名多个项目"
|
||||
if len(notices) != 1 || notices[0] != want {
|
||||
t.Fatalf("应给一条号码布重复提示 %q,得到 %+v", want, notices)
|
||||
}
|
||||
|
||||
// 号码布是同一个人的标识:只应建 1 份档案,两条报名挂在同一个人身上。
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE bib = 'B2001'`).Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 2 {
|
||||
t.Fatalf("同一个人报两个小项应各有一条报名,得到 %d", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布相同、两行证件号也相同 → 同一个人报多个项目,照旧放行;
|
||||
// 该说的提示由既有那条「证件号出现 N 次」承担,不要再叠一条号码布的,免得同一件事说两遍。
|
||||
func TestImportRepeatedBibWithSameIDCardIsOnePerson(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,110101199001011234,男,26,B2001,,registered,\n" +
|
||||
"M100,女子,A,张三,110101199001011234,男,26,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("同一个人两个组别都应落库: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("同一个人共用号码布不该报重号: %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
want := "证件号 110101199001011234 在本表中出现 2 次(同一人报名多个组别)"
|
||||
if len(notices) != 1 || notices[0] != want {
|
||||
t.Fatalf("只应给证件号那条提示 %q,得到 %+v", want, notices)
|
||||
}
|
||||
|
||||
var athletes int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM athletes WHERE id_card = ?`, "110101199001011234").Scan(&athletes); err != nil {
|
||||
t.Fatalf("统计人员: %v", err)
|
||||
}
|
||||
if athletes != 1 {
|
||||
t.Fatalf("同一个人只应建 1 个档案,得到 %d", athletes)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布相同、两行都没填证件号、又落在同一个小项同一个组别 = 真重复报名。
|
||||
// 口径:同一个人在同一组别只保留一条报名(后一行覆盖前一行),绝不建两条;
|
||||
// 因为多半是表格填错,所以给一条提示点名「同一小项同一组别有重复行」。
|
||||
func TestImportSameBibSameScopeTwiceKeepsOneEntry(t *testing.T) {
|
||||
env, _, client := recorderWithSeed(t)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,26,B2001,,registered,\n" +
|
||||
"M100,男子,A,张三,,男,26,B2001,,dns,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(1) || result["updated"] != float64(1) || result["skipped"] != float64(0) {
|
||||
t.Fatalf("首行新建、次行更新同一条报名: %+v", result)
|
||||
}
|
||||
if errs, _ := result["errors"].([]any); len(errs) != 0 {
|
||||
t.Fatalf("同一人同一组别的重复行不该报行级错误: %+v", errs)
|
||||
}
|
||||
notices, _ := result["notices"].([]any)
|
||||
want := "号码布 B2001 在本表中出现 2 次,其中同一小项同一组别有重复行:" +
|
||||
"同一个人的同一组别只保留一条报名(后一行覆盖前一行),请核对表格"
|
||||
if len(notices) != 1 || notices[0] != want {
|
||||
t.Fatalf("应提示同一组别重复 %q,得到 %+v", want, notices)
|
||||
}
|
||||
|
||||
var entries int
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT COUNT(*) FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entries); err != nil {
|
||||
t.Fatalf("统计报名: %v", err)
|
||||
}
|
||||
if entries != 1 {
|
||||
t.Fatalf("同一个人同一组别只应有一条报名,得到 %d", entries)
|
||||
}
|
||||
// 留下的那条是次行的值:后一行覆盖前一行。
|
||||
var entryStatus string
|
||||
if err := env.ev.QueryRow(
|
||||
`SELECT status FROM entries WHERE athlete_id = (SELECT id FROM athletes WHERE bib = 'B2001')`).Scan(&entryStatus); err != nil {
|
||||
t.Fatalf("查询报名状态: %v", err)
|
||||
}
|
||||
if entryStatus != "dns" {
|
||||
t.Fatalf("同一条报名应取后一行的状态 dns,得到 %s", entryStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// 号码布为空的行不进号码布判重:既不会与其他行串成重号,也不会多出号码布提示,
|
||||
// 该行自己的身份校验(证件号或号码布二选一)一字不变。
|
||||
func TestImportRowsWithoutBibUnaffected(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
env.exec(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '女子', 1, 1)`, ids.event)
|
||||
|
||||
body := importHeader + "\n" +
|
||||
"M100,男子,A,张三,,男,,,registered,\n" +
|
||||
"M100,女子,A,李四,110101199003031234,女,24,,,registered,\n" +
|
||||
"M100,女子,A,王五,,女,25,B2001,,registered,\n"
|
||||
status, env2 := env.postImport(t, client, "/api/entries/import", body, "")
|
||||
if status != http.StatusOK || !env2.OK {
|
||||
t.Fatalf("导入应 200,得到 %d %+v", status, env2.Error)
|
||||
}
|
||||
result := decodeData[map[string]any](t, env2)
|
||||
if result["created"] != float64(2) || result["skipped"] != float64(1) {
|
||||
t.Fatalf("两条无号码布的行照常导入,缺身份的那行照旧跳过: %+v", result)
|
||||
}
|
||||
errs, _ := result["errors"].([]any)
|
||||
if len(errs) != 1 || !strings.Contains(errs[0].(string), "必须填 id_card 或 bib") {
|
||||
t.Fatalf("缺证件号又没有号码布的行应报既有那条错误: %+v", errs)
|
||||
}
|
||||
if notices, _ := result["notices"].([]any); len(notices) != 0 {
|
||||
t.Fatalf("号码布只出现一次,不该有提示: %+v", notices)
|
||||
}
|
||||
}
|
||||
|
||||
// 证件号优先:库里某人 id_card=X / bib=A100,表里同证件号但号码布写错成 A200 时,
|
||||
// 命中同一个人(不新建、不报「证件号已被占用」),号码布差异按既有口径进冲突面板;
|
||||
// 决策 keep-new 后号码布按表里的值更新。
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package httpapi_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// @ 是分工作表模板里「小项名称@组别名称」的分隔符,所以它不许出现在小项名与组别名里。
|
||||
//
|
||||
// 三处入口都要拦:**基础数据界面**(前端,见 web/src/admin/tests/entry-template-sheet.test.ts)、
|
||||
// **规则包 YAML**(rules 包)、**服务端接口**(本文件)。
|
||||
// 界面能绕过去(改接口、导 Excel),所以服务端这一层才是真正的拦网;
|
||||
// 而接口层又不止一条路能写名字进来——建小项、改小项、整表替换组别、就地改组别、基础数据包,
|
||||
// 每条都钉一条用例,漏掉哪条都会留下一个「界面上拦得住、导 Excel 却塞得进去」的口子。
|
||||
|
||||
// assertNameSeparatorRejected 断言这次请求被 400 拦下,且文案说清了「为什么」与「怎么改」。
|
||||
func assertNameSeparatorRejected(t *testing.T, status int, env envelope) {
|
||||
t.Helper()
|
||||
|
||||
if status != http.StatusBadRequest || env.Error == nil {
|
||||
t.Fatalf("含 @ 的名字应被 400 拦下,得到 %d %+v", status, env.Error)
|
||||
}
|
||||
msg := env.Error.Message
|
||||
if !strings.Contains(msg, "分隔符") {
|
||||
t.Fatalf("报错要说清 @ 是模板里的分隔符:%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "组别名称") && !strings.Contains(msg, "小项名称") {
|
||||
t.Fatalf("报错要点明是小项名还是组别名:%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEventRejectsAtSignName(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
||||
"sportId": ids.sport, "code": "M600", "name": "600米@预赛",
|
||||
}, true)
|
||||
assertNameSeparatorRejected(t, status, resp)
|
||||
|
||||
// 拦下就是没落库:改名后再建,编码还能用。
|
||||
var count int
|
||||
if err := env.ev.QueryRow(`SELECT COUNT(*) FROM events WHERE code = 'M600'`).Scan(&count); err != nil {
|
||||
t.Fatalf("查小项: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("被拦下的请求不该留下小项,库里已有 %d 条", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateEventRejectsAtSignName(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, resp := env.do(client, http.MethodPut, "/api/events/"+strconv.FormatInt(ids.event, 10), map[string]any{
|
||||
"sportId": ids.sport, "code": "M100", "name": "100米@决赛",
|
||||
"eventType": "individual", "resultMode": "auto", "resultType": "time", "compare": "asc",
|
||||
"scored": true, "recordEligible": true, "awardMedals": true, "multiplier": 1, "status": "ongoing",
|
||||
}, true)
|
||||
assertNameSeparatorRejected(t, status, resp)
|
||||
|
||||
var name string
|
||||
if err := env.ev.QueryRow(`SELECT name FROM events WHERE id = ?`, ids.event).Scan(&name); err != nil {
|
||||
t.Fatalf("查小项名: %v", err)
|
||||
}
|
||||
if name != "100米跑" {
|
||||
t.Fatalf("被拦下的改名不该生效,库里仍是 %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceCategoriesRejectsAtSignName(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
// 种子里那条组别有报名,整表替换会被「已有报名数据」先拦下;另建一个干净的小项来测 @ 校验。
|
||||
clean := env.insert(t,
|
||||
`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
|
||||
VALUES (?, 'CLEAN', '干净的项', 'individual', 'auto', 'time', 'asc', 1, 1, 'pending')`, ids.sport)
|
||||
env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, clean)
|
||||
|
||||
status, resp := env.do(client, http.MethodPut, "/api/events/"+strconv.FormatInt(clean, 10)+"/categories", map[string]any{
|
||||
"categories": []map[string]any{
|
||||
{"name": "男子", "multiplier": 1, "sortOrder": 0},
|
||||
{"name": "女子@甲组", "multiplier": 1, "sortOrder": 1},
|
||||
},
|
||||
}, true)
|
||||
assertNameSeparatorRejected(t, status, resp)
|
||||
|
||||
// 整表替换是事务:一行不合法就整体回滚,原有组别必须一条不少。
|
||||
var names []string
|
||||
rows, err := env.ev.Query(`SELECT name FROM event_categories WHERE event_id = ? ORDER BY sort_order`, clean)
|
||||
if err != nil {
|
||||
t.Fatalf("查组别: %v", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("扫描组别名: %v", err)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
if len(names) != 1 || names[0] != "男子" {
|
||||
t.Fatalf("被拦下的替换不该改动库里的组别,得到 %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCategoryRejectsAtSignName(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, resp := env.do(client, http.MethodPut,
|
||||
"/api/events/"+strconv.FormatInt(ids.event, 10)+"/categories/"+strconv.FormatInt(ids.category, 10),
|
||||
map[string]any{"name": "男子@甲组", "multiplier": 1, "sortOrder": 0}, true)
|
||||
assertNameSeparatorRejected(t, status, resp)
|
||||
}
|
||||
|
||||
// 全角 @ 与半角 @ 在表名里肉眼分不出,一并拦下。
|
||||
func TestCreateEventRejectsFullWidthAtSign(t *testing.T) {
|
||||
env, ids, client := recorderWithSeed(t)
|
||||
|
||||
status, resp := env.do(client, http.MethodPost, "/api/events", map[string]any{
|
||||
"sportId": ids.sport, "code": "M700", "name": "700米@预赛",
|
||||
}, true)
|
||||
assertNameSeparatorRejected(t, status, resp)
|
||||
}
|
||||
|
||||
// 基础数据包(Excel)是第三条能写进小项名 / 组别名的路:dryRun 预览阶段就要报出来,
|
||||
// 不能等落地时才炸,也不能默默写进去。
|
||||
func TestBaseDataImportRejectsAtSignNames(t *testing.T) {
|
||||
env, _, _ := recorderWithSeed(t)
|
||||
// 基础数据包的导入是 admin 权限(影响面比录入大),这条用例要换个身份。
|
||||
env.createUser("admin", "admin-pass", "admin")
|
||||
client, _ := env.client()
|
||||
env.login(client, "admin", "admin-pass")
|
||||
|
||||
f := excelize.NewFile()
|
||||
defer func() { _ = f.Close() }()
|
||||
sheets := []struct {
|
||||
name string
|
||||
rows [][]string
|
||||
}{
|
||||
{"小项", [][]string{
|
||||
{"大项编码", "小项编码", "小项名称"},
|
||||
{"TRACK", "BAD1", "带@的小项"},
|
||||
}},
|
||||
{"组别", [][]string{
|
||||
{"小项编码", "组别名"},
|
||||
{"M100", "带@的组别"},
|
||||
}},
|
||||
}
|
||||
for i, sheet := range sheets {
|
||||
if i == 0 {
|
||||
_ = f.SetSheetName("Sheet1", sheet.name)
|
||||
} else {
|
||||
if _, err := f.NewSheet(sheet.name); err != nil {
|
||||
t.Fatalf("新建工作表: %v", err)
|
||||
}
|
||||
}
|
||||
for r, row := range sheet.rows {
|
||||
for c, v := range row {
|
||||
cell, err := excelize.CoordinatesToCellName(c+1, r+1)
|
||||
if err != nil {
|
||||
t.Fatalf("算单元格坐标: %v", err)
|
||||
}
|
||||
if err := f.SetCellValue(sheet.name, cell, v); err != nil {
|
||||
t.Fatalf("写单元格: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data := workbookBytes(t, f)
|
||||
|
||||
status, raw := env.uploadBaseData(t, client, "basedata.xlsx", data, map[string]string{"dryRun": "1"})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("dryRun 预览应 200,得到 %d:%s", status, raw)
|
||||
}
|
||||
result := decodeRaw[baseDataResult](t, raw)
|
||||
if result.Added != 0 {
|
||||
t.Fatalf("含 @ 的行不该进计划:%+v", result)
|
||||
}
|
||||
if result.ErrorCount < 2 {
|
||||
t.Fatalf("小项与组别两条都要报错,得到 %+v", result.Errors)
|
||||
}
|
||||
joined := strings.Join(result.Errors, ";")
|
||||
if !strings.Contains(joined, "分隔符") || !strings.Contains(joined, "带@的小项") || !strings.Contains(joined, "带@的组别") {
|
||||
t.Fatalf("报错要点名两个名字并说清理由:%v", result.Errors)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,10 @@ func (s *server) createEvent(ctx context.Context, in eventDTO) (eventDTO, error)
|
||||
if in.SportID == 0 || in.Code == "" || in.Name == "" {
|
||||
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
|
||||
}
|
||||
// 小项名会被拼进分工作表模板的工作表名(「小项名称@组别名称」),名字里再出现 @ 就拆不开。
|
||||
if err := checkNameSeparator("小项名称", in.Name); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
avgMode, err := normalizeAvgMode(in.AvgMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
@@ -169,6 +173,10 @@ func (s *server) updateEvent(ctx context.Context, id int64, in eventDTO) (eventD
|
||||
if in.SportID == 0 || in.Code == "" || in.Name == "" {
|
||||
return eventDTO{}, badRequest("sportId、code、name 必填", nil)
|
||||
}
|
||||
// 与新建同口径:@ 是分工作表模板里小项与组别的分隔符,改名时也不许出现。
|
||||
if err := checkNameSeparator("小项名称", in.Name); err != nil {
|
||||
return eventDTO{}, err
|
||||
}
|
||||
avgMode, err := normalizeAvgMode(in.AvgMode)
|
||||
if err != nil {
|
||||
return eventDTO{}, err
|
||||
@@ -422,6 +430,10 @@ func (s *server) replaceCategories(ctx context.Context, eventID int64, in []cate
|
||||
if strings.TrimSpace(c.Name) == "" {
|
||||
return nil, badRequest("组别 name 必填", i)
|
||||
}
|
||||
// 组别名会被拼进分工作表模板的工作表名(「小项名称@组别名称」),名字里不能有 @。
|
||||
if err := checkNameSeparator("组别名称", c.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCategoryGender(c.Gender); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -527,6 +539,10 @@ func (s *server) updateCategory(ctx context.Context, eventID, categoryID int64,
|
||||
if name == "" {
|
||||
return nil, false, badRequest("组别 name 必填", nil)
|
||||
}
|
||||
// 与整表替换同口径:组别名里不能有 @(分工作表模板里它是小项与组别的分隔符)。
|
||||
if err := checkNameSeparator("组别名称", name); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := validateCategoryGender(in.Gender); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
@@ -268,6 +268,9 @@ func (s *server) routes(mux *http.ServeMux) {
|
||||
|
||||
mux.HandleFunc("POST /api/entries/import", s.write(auth.RoleRecorder, s.importEntriesHandler))
|
||||
mux.HandleFunc("POST /api/entries/import/resolve", s.write(auth.RoleRecorder, s.resolveImportHandler))
|
||||
// 分工作表报名模板:按当前赛事的「小项 × 组别」生成,一个小项组别一张工作表(entries_sheet_template.go)。
|
||||
// 是报名流程的一部分,权限与导入同档(recorder 及以上);它只读基础数据,不产生任何写入。
|
||||
mux.HandleFunc("GET /api/entries/template.xlsx", s.read(auth.RoleRecorder, s.entriesTemplateXLSX))
|
||||
// 批次(heat)是录入 / 展示分组,不参与算分,所以没有独立的「发布」概念。
|
||||
mux.HandleFunc("PUT /api/entries/heat", s.write(auth.RoleRecorder, s.setEntriesHeatHandler))
|
||||
// 报名项批量操作:退赛(scratched,可恢复)/ 恢复 / 移出报名(物理删除,不可恢复)。
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 规则包里的小项名(eventOverrides[].name)不许含 @。
|
||||
//
|
||||
// @ 是分工作表报名模板里「小项名称@组别名称」的分隔符(见 httpapi/entries_sheet_template.go)。
|
||||
// 规则包会被现场复用到别的赛事,而这个名字正是表名的一半:带 @ 的名字复制过去,
|
||||
// 那个赛事的分工作表模板就生成不出来,现场只看得到一句与规则毫无关系的报错。
|
||||
//
|
||||
// 检查放在 ValidateForImport 而不是 Validate:导出的规则包(GET /api/rules/export、赛事项目包里的
|
||||
// rules.yaml)是现场备份与排障的入口,历史库里已经存在的名字必须照样能导出来。
|
||||
|
||||
// atSignPackYAML 是「小项名里带 @」的规则包;名字由用例替换。
|
||||
func atSignPackYAML(name string) string {
|
||||
return `format: scoreboard-rules
|
||||
formatVersion: 1
|
||||
meta: { code: demo, name: 带 @ 的小项名 }
|
||||
profiles:
|
||||
- code: p6
|
||||
name: 前六名
|
||||
awardRanks: 6
|
||||
tieMode: skip
|
||||
rules:
|
||||
- { rank: 1, points: 10 }
|
||||
- { rank: 2, points: 8 }
|
||||
channelTemplates:
|
||||
- match: { eventType: individual }
|
||||
channels:
|
||||
- { scope: athlete, basis: result, profile: p6 }
|
||||
eventOverrides:
|
||||
- eventCode: M100
|
||||
name: "` + name + `"
|
||||
`
|
||||
}
|
||||
|
||||
func TestParseRejectsAtSignInOverrideName(t *testing.T) {
|
||||
_, err := Parse([]byte(atSignPackYAML("100米@跑")))
|
||||
if !errors.Is(err, ErrValidation) {
|
||||
t.Fatalf("小项名含 @ 应报 ErrValidation,实际 %v", err)
|
||||
}
|
||||
var ve *ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("应返回 *ValidationError,实际 %T", err)
|
||||
}
|
||||
var found bool
|
||||
for _, is := range ve.Issues {
|
||||
if is.Path != "eventOverrides[0].name" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
// 文案要说清三件事:哪个名字、@ 是什么、怎么办。
|
||||
for _, want := range []string{"100米@跑", "分隔符", "小项名称@组别名称", "改成"} {
|
||||
if !strings.Contains(is.Msg, want) {
|
||||
t.Fatalf("说明里缺少 %q:%s", want, is.Msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("应把问题指到 eventOverrides[0].name,得到 %+v", ve.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
// 全角 @ 与半角 @ 在表名里肉眼分不出,一并拦下。
|
||||
func TestParseRejectsFullWidthAtSignInOverrideName(t *testing.T) {
|
||||
if _, err := Parse([]byte(atSignPackYAML("100米@跑"))); !errors.Is(err, ErrValidation) {
|
||||
t.Fatalf("全角 @ 也应被拦下,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 没写 name(可选字段)与正常名字照样放行:不能因为这条新检查把规则包导入弄坏。
|
||||
func TestParseAcceptsOverrideWithoutAtSign(t *testing.T) {
|
||||
for _, name := range []string{"", "团体引体向上", "100米跑-决赛"} {
|
||||
if _, err := Parse([]byte(atSignPackYAML(name))); err != nil {
|
||||
t.Fatalf("小项名 %q 应照常导入,得到 %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,9 +101,11 @@ func (p *RulePack) Validate() error {
|
||||
|
||||
// ValidateForImport 是导入侧在 Validate 之外额外拦下的「这份配置根本算不出分」检查。
|
||||
//
|
||||
// 目前只有一条:分值表的档位超出录取档位数(awardRanks)。见 AwardRanksOverflow。
|
||||
// 两条:分值表的档位超出录取档位数(awardRanks,见 AwardRanksOverflow),
|
||||
// 以及小项名里出现分工作表模板的分隔符 @(见 eventOverrides[].name)。
|
||||
// 单独成一个方法而不是塞进 Validate,是因为 Validate 同时被导出路径用着——历史库里已经存在的
|
||||
// 越档模板必须照样能导出(否则备份与规则导出会 500),但绝不能再从导入这条路进来。
|
||||
// 越档模板必须照样能导出(否则备份与规则导出会 500),但绝不能再从导入这条路进来;
|
||||
// 名字里带 @ 的历史规则包同理:还能导出、还能留在库里,但不许再导进来。
|
||||
func (p *RulePack) ValidateForImport() error {
|
||||
var l issueList
|
||||
for i, prof := range p.Profiles {
|
||||
@@ -118,12 +120,29 @@ func (p *RulePack) ValidateForImport() error {
|
||||
l.addf(fmt.Sprintf("profiles[%d].awardRanks", i), "%s", msg)
|
||||
}
|
||||
}
|
||||
// 规则包里的小项名(eventOverrides[].name)会随规则包一起被现场复用到别的赛事,
|
||||
// 而它正是「小项名称@组别名称」的一半:名字里带 @,那个赛事的分工作表模板就生成不出来。
|
||||
for i, ov := range p.EventOverrides {
|
||||
if strings.IndexAny(ov.Name, "@@") < 0 {
|
||||
continue
|
||||
}
|
||||
l.addf(fmt.Sprintf("eventOverrides[%d].name", i), "%s", ruleNameSeparatorMessage(ov.Name))
|
||||
}
|
||||
if len(l.issues) > 0 {
|
||||
return &ValidationError{Issues: l.issues}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ruleNameSeparatorMessage 与 httpapi 的 atSignNameMessage 是同一件事的同一句话
|
||||
// (规则包属于 rules 包、接口层属于 httpapi 包,两边不互相 import,只能各写一份)。
|
||||
// 改这里必须同步改 server/internal/httpapi/entries_sheet_template.go 的那份。
|
||||
func ruleNameSeparatorMessage(name string) string {
|
||||
return fmt.Sprintf("小项名称「%s」里不能有 @(也不能有全角 @):@ 是分工作表报名模板里"+
|
||||
"「小项名称@组别名称」的分隔符,写进名字会让工作表名拆不开、模板生成不出来。"+
|
||||
"请把规则包里这个名字改成与现场一致、且不含 @ 的写法", name)
|
||||
}
|
||||
|
||||
func validateTieBreak(l *issueList, tieBreak []string) {
|
||||
seen := make(map[string]bool, len(tieBreak))
|
||||
for i, key := range tieBreak {
|
||||
|
||||
@@ -210,6 +210,35 @@ func main() {
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("关闭超时", "err", err)
|
||||
}
|
||||
|
||||
// 自动备份协程必须在这里 join,否则进程可能在 VACUUM INTO 中途退出。
|
||||
// 它不等锁吗?会等:源库被别的连接锁着时,VACUUM INTO 会带着一个 0 字节的目标文件
|
||||
// 在那里等锁(直到 busy_timeout)——进程此时退出,那个半截文件就留在备份目录里。
|
||||
// 上限 10 秒是给「卡在锁上」留的(SQLite busy_timeout 5 秒);大库的 VACUUM 也可能更久,
|
||||
// 但 Console 的关闭握手等不起,超时就照常退出:残余的只会是一个中间文件
|
||||
// <备份名>.tmp-<随机>,它不在备份列表里,也没人会拿它去恢复。
|
||||
if !waitBackupStop(backupSvc, 10*time.Second) {
|
||||
logger.Warn("自动备份未在限期内收尾,先退出;残余的中间文件不会出现在备份列表里")
|
||||
}
|
||||
}
|
||||
|
||||
// waitBackupStop 在退出前 join 自动备份协程,最多等 timeout;返回 false 表示等超时了。
|
||||
//
|
||||
// 没起过自动备份(间隔 <=0、没配数据目录)时立即返回 true:正常关停不会被它拖住。
|
||||
func waitBackupStop(svc *export.Service, timeout time.Duration) bool {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
svc.Wait()
|
||||
close(done)
|
||||
}()
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// initialAdminBanner 组装初始管理员提示块:初始密码只在这里一次性输出,不写入结构化日志。
|
||||
|
||||
Reference in new issue
Block a user