Files
Scoreboard/server/internal/export/project.go
T
AskaEth 13f1f03cd0 fix: 修掉十路审查发现的 9 个 P0 与一批 P1(数据正确性 / 越权 / 现场可用性)
P0(9 条全修):
- 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重,
  通道优先级 athlete→team→id
- 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一
- aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位
- 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑
- voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks
- 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚
- 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400
- Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底
- /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings

P1(计分口径 / 契约 / 现场):
- standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验
  RecordEligible 与 entry 归属;破纪录登记校验已发布成绩
- export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正
  (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示
- 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/
  recalculatedLedgerRows;登录审计 user_id 修正
- WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId;
  实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新
- 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断;
  REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出
- 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端
  points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护;
  发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口
- Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII
  (已在 Meowhome 实跑验证)

自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、
node --test 24/24;console check-syntax 12 文件通过。
2026-09-09 22:16:58 +08:00

662 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package export
import (
"archive/zip"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
_ "modernc.org/sqlite"
)
const (
// projectFormat 是赛事项目包的标识,导入时用来挡住「拿错文件」。
projectFormat = "scoreboard-event-project"
// projectFormatVersion 是当前程序能读写的包格式版本。
projectFormatVersion = 1
// eventDBName 是包内赛事库的固定文件名。
eventDBName = "event.db"
)
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
// 定义为变量便于测试用较小的上限验证限流逻辑。
var maxExtractBytes int64 = 4 << 30
// ProjectFile 描述包内一个文件的校验信息。
type ProjectFile struct {
Path string `yaml:"path"`
Size int64 `yaml:"size"`
SHA256 string `yaml:"sha256"`
}
// ProjectMeta 是包内 meta.yaml 的映射;导入时先校验 format/formatVersion 与校验和。
type ProjectMeta struct {
Format string `yaml:"format"`
FormatVersion int `yaml:"formatVersion"`
Name string `yaml:"name"`
EventName string `yaml:"eventName,omitempty"`
Subtitle string `yaml:"subtitle,omitempty"`
Organizer string `yaml:"organizer,omitempty"`
CreatedAt time.Time `yaml:"createdAt"`
CreatedBy string `yaml:"createdBy,omitempty"`
AppVersion string `yaml:"appVersion,omitempty"`
Database ProjectFile `yaml:"database"`
Files []ProjectFile `yaml:"files"`
}
// ProjectExportOptions 描述一次赛事包导出。
type ProjectExportOptions struct {
Name string // 项目名,导入后落成 data/event_projs/<Name>/
EventName string // 留空则读赛事库 settings
Subtitle string
Organizer string
CreatedBy string
AppVersion string
UploadsDir string // 可选:背景图 / 会标目录
FontsDir string // 可选:字体目录
IncludeFonts bool // 是否把字体一并打包
RulesPath string // 可选:规则包 YAML
Now time.Time
}
// ImportOptions 描述一次赛事包导入。
type ImportOptions struct {
// Overwrite 为 true 时覆盖同名项目;覆盖前旧目录会先改名,避免删到一半失败。
Overwrite bool
// Rename 非空时用该名字作为目录名(解决「两台机器都有同名赛事」)。
Rename string
}
// ImportResult 是导入结果。
type ImportResult struct {
Name string `json:"name"`
Dir string `json:"dir"`
EventDB string `json:"eventDB"`
Meta ProjectMeta `json:"meta"`
}
// ExportProjectZip 把当前赛事库、uploads、可选字体与规则包打成一个 zip。
// 包内 event.db 用 VACUUM INTO 生成,即使服务正在写入也是一致快照。
func ExportProjectZip(db *sql.DB, dst string, opts ProjectExportOptions) (ProjectMeta, error) {
if db == nil {
return ProjectMeta{}, ErrNoDatabase
}
name, err := SafeProjectName(opts.Name)
if err != nil {
return ProjectMeta{}, err
}
meta, _, err := resolveMeta(db, Meta{EventName: opts.EventName, Subtitle: opts.Subtitle, Organizer: opts.Organizer})
if err != nil {
return ProjectMeta{}, err
}
tmp, err := os.MkdirTemp("", "scoreboard-project-*")
if err != nil {
return ProjectMeta{}, fmt.Errorf("export: 创建临时目录: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := vacuumInto(db, filepath.Join(tmp, eventDBName)); err != nil {
return ProjectMeta{}, err
}
// uploads 即使为空也要保留目录,导入方才知道该往哪放资源。
uploads := filepath.Join(tmp, "uploads")
if err := os.MkdirAll(uploads, 0o755); err != nil {
return ProjectMeta{}, fmt.Errorf("export: 创建 uploads 目录: %w", err)
}
if opts.UploadsDir != "" {
if err := copyTree(opts.UploadsDir, uploads); err != nil {
return ProjectMeta{}, err
}
}
if opts.IncludeFonts && opts.FontsDir != "" {
if err := copyTree(opts.FontsDir, filepath.Join(tmp, "fonts")); err != nil {
return ProjectMeta{}, err
}
}
if opts.RulesPath != "" {
if err := copyFile(opts.RulesPath, filepath.Join(tmp, "rules.yaml")); err != nil {
return ProjectMeta{}, err
}
}
files, err := buildManifest(tmp)
if err != nil {
return ProjectMeta{}, err
}
dbFile, ok := findFile(files, eventDBName)
if !ok {
return ProjectMeta{}, fmt.Errorf("%w: 缺少 %s", ErrInvalidPackage, eventDBName)
}
pkg := ProjectMeta{
Format: projectFormat,
FormatVersion: projectFormatVersion,
Name: name,
EventName: meta.EventName,
Subtitle: meta.Subtitle,
Organizer: meta.Organizer,
CreatedAt: nowOr(opts.Now),
CreatedBy: opts.CreatedBy,
AppVersion: opts.AppVersion,
Database: dbFile,
Files: files,
}
raw, err := yaml.Marshal(&pkg)
if err != nil {
return ProjectMeta{}, fmt.Errorf("export: 生成 meta.yaml: %w", err)
}
if err := os.WriteFile(filepath.Join(tmp, "meta.yaml"), raw, 0o644); err != nil {
return ProjectMeta{}, fmt.Errorf("export: 写入 meta.yaml: %w", err)
}
if err := zipDir(tmp, dst); err != nil {
return ProjectMeta{}, err
}
return pkg, nil
}
// InspectProjectZip 只读取包内 meta.yaml 并校验格式,不解压——导入前预览用。
func InspectProjectZip(src string) (ProjectMeta, error) {
r, err := zip.OpenReader(src)
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
}
defer func() { _ = r.Close() }()
for _, f := range r.File {
if path.Clean(strings.ReplaceAll(f.Name, "\\", "/")) != "meta.yaml" {
continue
}
rc, err := f.Open()
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
}
defer func() { _ = rc.Close() }()
raw, err := io.ReadAll(io.LimitReader(rc, 1<<20))
if err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 读取 meta.yaml: %v", ErrInvalidPackage, err)
}
var meta ProjectMeta
if err := yaml.Unmarshal(raw, &meta); err != nil {
return ProjectMeta{}, fmt.Errorf("%w: 解析 meta.yaml: %v", ErrInvalidPackage, err)
}
if err := validateMeta(meta); err != nil {
return ProjectMeta{}, err
}
return meta, nil
}
return ProjectMeta{}, fmt.Errorf("%w: 缺少 meta.yaml", ErrInvalidPackage)
}
func validateMeta(meta ProjectMeta) error {
if meta.Format != projectFormat {
return fmt.Errorf("%w: format=%q", ErrInvalidPackage, meta.Format)
}
if meta.FormatVersion < 1 || meta.FormatVersion > projectFormatVersion {
return fmt.Errorf("%w: formatVersion=%d(本程序支持 1..%d)", ErrUnsupportedVersion, meta.FormatVersion, projectFormatVersion)
}
if _, err := SafeProjectName(meta.Name); err != nil {
return err
}
if path.Clean(meta.Database.Path) != eventDBName {
return fmt.Errorf("%w: database.path=%q", ErrInvalidPackage, meta.Database.Path)
}
if meta.Database.SHA256 == "" {
return fmt.Errorf("%w: meta.yaml 缺少 event.db 校验和", ErrInvalidPackage)
}
return nil
}
// ImportProjectZip 校验并解压赛事包到 projRoot/<项目名>/,返回落盘位置。
func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, error) {
meta, err := InspectProjectZip(src)
if err != nil {
return ImportResult{}, err
}
name := meta.Name
if strings.TrimSpace(opts.Rename) != "" {
name = opts.Rename
}
name, err = SafeProjectName(name)
if err != nil {
return ImportResult{}, err
}
if err := os.MkdirAll(projRoot, 0o755); err != nil {
return ImportResult{}, fmt.Errorf("export: 创建赛事项目根目录: %w", err)
}
tmp, err := os.MkdirTemp(projRoot, ".import-*")
if err != nil {
return ImportResult{}, fmt.Errorf("export: 创建导入临时目录: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := extractZip(src, tmp); err != nil {
return ImportResult{}, err
}
if err := verifyManifest(tmp, meta); err != nil {
return ImportResult{}, err
}
eventDB := filepath.Join(tmp, eventDBName)
if err := verifySQLite(eventDB); err != nil {
return ImportResult{}, err
}
target := filepath.Join(projRoot, name)
if err := swapProjectDir(tmp, target, opts.Overwrite, os.Rename); err != nil {
return ImportResult{}, err
}
return ImportResult{
Name: name,
Dir: target,
EventDB: filepath.Join(target, eventDBName),
Meta: meta,
}, nil
}
// swapProjectDir 用 src 目录替换 target 目录。
// 覆盖时先把旧目录改名暂存,src 落盘成功后才删除暂存目录;src 落盘失败则把旧目录改回来,
// 绝不出现「旧项目已删、新项目没成」的空档。rename 注入仅为测试模拟第二次改名失败。
func swapProjectDir(src, target string, overwrite bool, rename func(old, new string) error) error {
name := filepath.Base(target)
stale := ""
if _, err := os.Stat(target); err == nil {
if !overwrite {
return fmt.Errorf("%w: %s", ErrProjectExists, name)
}
stale = target + ".replaced-" + time.Now().Format("20060102-150405")
if err := rename(target, stale); err != nil {
return fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("export: 检查项目目录 %s: %w", target, err)
}
if err := rename(src, target); err != nil {
if stale != "" {
if rbErr := rename(stale, target); rbErr != nil {
return fmt.Errorf("export: 落盘赛事项目 %s: %w(旧项目回滚失败,暂存在 %s: %v)", name, err, stale, rbErr)
}
}
return fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
}
if stale != "" {
// 新项目已就位,清理旧目录失败不影响本次导入,留待下次人工处理。
_ = os.RemoveAll(stale)
}
return nil
}
// SafeProjectName 校验项目名:它会被当作目录名,必须挡住路径穿越与 Windows 保留名。
func SafeProjectName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", fmt.Errorf("%w: 名称为空", ErrUnsafeName)
}
if name == "." || name == ".." {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
if len([]rune(name)) > 64 {
return "", fmt.Errorf("%w: 名称超过 64 个字符", ErrUnsafeName)
}
if strings.ContainsAny(name, `/\:*?"<>|`) || strings.ContainsRune(name, 0) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
// Windows 会静默剥掉目录名末尾的点与空格,"赛事." 与 "赛事" 会撞成同一个目录。
if strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return "", fmt.Errorf("%w: %q 不能以点或空格结尾", ErrUnsafeName, name)
}
for _, r := range name {
if r < 0x20 {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
}
if filepath.IsAbs(name) {
return "", fmt.Errorf("%w: %q", ErrUnsafeName, name)
}
base := strings.ToUpper(strings.TrimSuffix(name, filepath.Ext(name)))
switch base {
case "CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
return "", fmt.Errorf("%w: %q 是系统保留名", ErrUnsafeName, name)
}
return name, nil
}
func vacuumInto(db *sql.DB, dst string) error {
// VACUUM INTO 的目标是语句里的字面量,路径由本包自己生成,转义单引号即可。
stmt := "VACUUM INTO '" + strings.ReplaceAll(dst, "'", "''") + "'"
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("export: VACUUM INTO %s: %w", dst, err)
}
return nil
}
func buildManifest(root string) ([]ProjectFile, error) {
var out []ProjectFile
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if rel == "meta.yaml" {
return nil
}
sum, size, err := hashFile(p)
if err != nil {
return err
}
out = append(out, ProjectFile{Path: rel, Size: size, SHA256: sum})
return nil
})
if err != nil {
return nil, fmt.Errorf("export: 生成文件清单: %w", err)
}
return out, nil
}
func findFile(files []ProjectFile, name string) (ProjectFile, bool) {
for _, f := range files {
if f.Path == name {
return f, true
}
}
return ProjectFile{}, false
}
func hashFile(p string) (string, int64, error) {
f, err := os.Open(p)
if err != nil {
return "", 0, fmt.Errorf("export: 读取 %s: %w", p, err)
}
defer func() { _ = f.Close() }()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return "", 0, fmt.Errorf("export: 计算 %s 校验和: %w", p, err)
}
return hex.EncodeToString(h.Sum(nil)), n, nil
}
func copyTree(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return fmt.Errorf("export: 读取目录 %s: %w", src, err)
}
if !info.IsDir() {
return fmt.Errorf("export: %s 不是目录", src)
}
if err := os.MkdirAll(dst, 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", dst, err)
}
return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(p, target)
})
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("export: 读取 %s: %w", src, err)
}
defer func() { _ = in.Close() }()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(dst), err)
}
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("export: 创建 %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("export: 复制 %s → %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("export: 关闭 %s: %w", dst, err)
}
return nil
}
// zipDir 先写临时文件再改名,避免导出中途失败留下半截 zip。
func zipDir(root, dst string) error {
if dir := filepath.Dir(dst); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("export: 创建导出目录 %s: %w", dir, err)
}
}
tmp := dst + ".tmp"
out, err := os.Create(tmp)
if err != nil {
return fmt.Errorf("export: 创建 %s: %w", tmp, err)
}
zw := zip.NewWriter(out)
if err := writeZipTree(zw, root); err != nil {
_ = zw.Close()
_ = out.Close()
_ = os.Remove(tmp)
return err
}
if err := zw.Close(); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return fmt.Errorf("export: 关闭 zip: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 关闭 %s: %w", tmp, err)
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("export: 重命名 %s: %w", dst, err)
}
return nil
}
func writeZipTree(zw *zip.Writer, root string) error {
return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if rel == "." {
return nil
}
name := filepath.ToSlash(rel)
if d.IsDir() {
// 保留空目录(如空的 uploads/),导入方才能得到完整结构。
_, err := zw.Create(name + "/")
return err
}
info, err := d.Info()
if err != nil {
return err
}
hdr, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
hdr.Name = name
hdr.Method = zip.Deflate
w, err := zw.CreateHeader(hdr)
if err != nil {
return err
}
src, err := os.Open(p)
if err != nil {
return fmt.Errorf("export: 读取 %s: %w", p, err)
}
_, copyErr := io.Copy(w, src)
closeErr := src.Close()
if copyErr != nil {
return fmt.Errorf("export: 写入 zip 条目 %s: %w", name, copyErr)
}
return closeErr
})
}
func extractZip(src, dst string) error {
r, err := zip.OpenReader(src)
if err != nil {
return fmt.Errorf("%w: 打开 zip %s: %v", ErrInvalidPackage, src, err)
}
defer func() { _ = r.Close() }()
var total int64
for _, f := range r.File {
target, err := secureJoin(dst, f.Name)
if err != nil {
return err
}
if f.FileInfo().IsDir() {
if err := os.MkdirAll(target, 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", target, err)
}
continue
}
// 声明值先卡一道,避免一上来就落盘;注意先判上界再转 int64,防止溢出。
if f.UncompressedSize64 > uint64(maxExtractBytes) || total+int64(f.UncompressedSize64) > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return fmt.Errorf("export: 创建目录 %s: %w", filepath.Dir(target), err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("%w: 读取条目 %s: %v", ErrInvalidPackage, f.Name, err)
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
_ = rc.Close()
return fmt.Errorf("export: 写入 %s: %w", target, err)
}
// header 里的声明值可以伪造,按实际写出的字节数再卡一次上限。
n, copyErr := io.Copy(out, io.LimitReader(rc, maxExtractBytes-total+1))
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return fmt.Errorf("export: 解压 %s: %w", f.Name, copyErr)
}
total += n
if total > maxExtractBytes {
return fmt.Errorf("%w: 解压体积超过 %d 字节", ErrInvalidPackage, maxExtractBytes)
}
if closeOutErr != nil {
return fmt.Errorf("export: 关闭 %s: %w", target, closeOutErr)
}
if closeInErr != nil {
return fmt.Errorf("export: 关闭条目 %s: %w", f.Name, closeInErr)
}
}
return nil
}
// secureJoin 把 zip 条目名限制在 root 之内,挡住 ../ 与绝对路径(zip slip)。
func secureJoin(root, name string) (string, error) {
clean := path.Clean(strings.ReplaceAll(name, "\\", "/"))
if clean == "." {
return root, nil
}
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
}
target := filepath.Join(root, filepath.FromSlash(clean))
rel, err := filepath.Rel(root, target)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: 非法路径 %q", ErrInvalidPackage, name)
}
return target, nil
}
func verifyManifest(root string, meta ProjectMeta) error {
if len(meta.Files) == 0 {
return fmt.Errorf("%w: meta.yaml 缺少文件清单", ErrInvalidPackage)
}
for _, f := range meta.Files {
target, err := secureJoin(root, f.Path)
if err != nil {
return err
}
sum, size, err := hashFile(target)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidPackage, err)
}
if f.Size != 0 && f.Size != size {
return fmt.Errorf("%w: %s 大小 %d ≠ %d", ErrChecksumMismatch, f.Path, size, f.Size)
}
if !strings.EqualFold(sum, f.SHA256) {
return fmt.Errorf("%w: %s", ErrChecksumMismatch, f.Path)
}
}
dbFile, ok := findFile(meta.Files, eventDBName)
if !ok {
return fmt.Errorf("%w: 文件清单缺少 %s", ErrInvalidPackage, eventDBName)
}
if !strings.EqualFold(dbFile.SHA256, meta.Database.SHA256) {
return fmt.Errorf("%w: database.sha256 与清单不一致", ErrChecksumMismatch)
}
return nil
}
// verifySQLite 确认包内 event.db 确实是完整可读的赛事库,而不是改了后缀的其它文件。
func verifySQLite(p string) error {
db, err := sql.Open("sqlite", "file:"+p+"?mode=ro")
if err != nil {
return fmt.Errorf("%w: 打开 %s: %v", ErrInvalidPackage, p, err)
}
defer func() { _ = db.Close() }()
var integrity string
if err := db.QueryRow(`PRAGMA integrity_check`).Scan(&integrity); err != nil {
return fmt.Errorf("%w: 完整性检查: %v", ErrInvalidPackage, err)
}
if !strings.EqualFold(integrity, "ok") {
return fmt.Errorf("%w: 完整性检查结果 %q", ErrInvalidPackage, integrity)
}
var n int
if err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n); err != nil {
return fmt.Errorf("%w: 读取表结构: %v", ErrInvalidPackage, err)
}
if n == 0 {
return fmt.Errorf("%w: %s 不是赛事库(缺少 schema_migrations)", ErrInvalidPackage, eventDBName)
}
return nil
}