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 文件通过。
This commit is contained in:
1 parent
96cf181ae3
commit
13f1f03cd0
124 files changed
+5658
-1820
No files matched your search
@@ -67,7 +67,10 @@ func Backup(db *sql.DB, opts BackupOptions) (BackupInfo, error) {
|
||||
}
|
||||
now := nowOr(opts.Now)
|
||||
|
||||
path := uniqueBackupPath(dir, prefix, reason, now)
|
||||
path, err := uniqueBackupPath(dir, prefix, reason, now)
|
||||
if err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
if err := vacuumInto(db, path); err != nil {
|
||||
return BackupInfo{}, err
|
||||
}
|
||||
@@ -286,23 +289,40 @@ func PruneBackups(dir, prefix string, keep int) ([]string, error) {
|
||||
return removed, errors.Join(errs...)
|
||||
}
|
||||
|
||||
func uniqueBackupPath(dir, prefix, reason string, now time.Time) string {
|
||||
// uniqueBackupPath 为备份选一个不重名的路径。
|
||||
// 重试次数有上限:目录不可读等非 NotExist 错误不能让备份线程死循环(它持锁会阻塞全部备份)。
|
||||
func uniqueBackupPath(dir, prefix, reason string, now time.Time) (string, error) {
|
||||
base := fmt.Sprintf("%s-%s-%s", prefix, reason, now.Format("20060102-150405"))
|
||||
p := filepath.Join(dir, base+".db")
|
||||
for i := 2; ; i++ {
|
||||
if _, err := os.Stat(p); os.IsNotExist(err) {
|
||||
return p
|
||||
for i := 2; i <= 100; i++ {
|
||||
_, err := os.Stat(p)
|
||||
if os.IsNotExist(err) {
|
||||
return p, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("export: 检查备份路径 %s: %w", p, err)
|
||||
}
|
||||
p = filepath.Join(dir, fmt.Sprintf("%s-%d.db", base, i))
|
||||
}
|
||||
return "", fmt.Errorf("export: 备份文件名 %s 冲突过多,放弃重试", base)
|
||||
}
|
||||
|
||||
// knownBackupPrefixes 是系统内置的备份前缀;prefix 为空(列出全部备份)时用它识别并剥掉前缀。
|
||||
var knownBackupPrefixes = []string{"event", "system", "db"}
|
||||
|
||||
// reasonFromName 从 <prefix>-<reason>-<yyyyMMdd>-<HHmmss>[-<序号>].db 里取回原因;
|
||||
// 同一秒重复备份会多一段序号,所以从右侧找「8 位日期 + 6 位时间」再截断。
|
||||
func reasonFromName(name, prefix string) string {
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if prefix != "" {
|
||||
base = strings.TrimPrefix(base, prefix+"-")
|
||||
} else {
|
||||
for _, p := range knownBackupPrefixes {
|
||||
if strings.HasPrefix(base, p+"-") {
|
||||
base = strings.TrimPrefix(base, p+"-")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
parts := strings.Split(base, "-")
|
||||
for i := len(parts) - 2; i >= 0; i-- {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -236,6 +237,88 @@ func TestServiceRunMutualExclusion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// prefix 为空(列出全部备份)时要剥掉已识别的前缀,不能把 event-/system- 拼进原因。
|
||||
func TestReasonFromNameWithoutPrefix(t *testing.T) {
|
||||
cases := []struct{ name, want string }{
|
||||
{"event-auto-20260909-213614.db", "auto"},
|
||||
{"event-publish-20260909-213614-2.db", "publish"},
|
||||
{"system-manual-20260909-213614.db", "manual"},
|
||||
{"db-manual-20260909-213614.db", "manual"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := reasonFromName(c.name, ""); got != c.want {
|
||||
t.Errorf("reasonFromName(%q, \"\") = %q,期望 %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListBackups 不传 prefix 时也要解析出可读原因(管理端「备份」面板按原因分列)。
|
||||
func TestListBackupsParsesReasonWithoutPrefix(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
dir := t.TempDir()
|
||||
base := time.Date(2026, 9, 9, 12, 0, 0, 0, time.Local)
|
||||
|
||||
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "event", Reason: "auto", Now: base, SkipPrune: true}); err != nil {
|
||||
t.Fatalf("赛事库备份: %v", err)
|
||||
}
|
||||
if _, err := Backup(db, BackupOptions{Dir: dir, Prefix: "system", Reason: "manual", Now: base.Add(time.Minute), SkipPrune: true}); err != nil {
|
||||
t.Fatalf("系统库备份: %v", err)
|
||||
}
|
||||
|
||||
list, err := ListBackups(dir, "")
|
||||
if err != nil {
|
||||
t.Fatalf("列出全部备份: %v", err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("备份数 = %d,期望 2", len(list))
|
||||
}
|
||||
want := map[string]string{"event": "auto", "system": "manual"}
|
||||
for _, b := range list {
|
||||
var prefix string
|
||||
for p := range want {
|
||||
if strings.HasPrefix(b.Name, p+"-") {
|
||||
prefix = p
|
||||
}
|
||||
}
|
||||
if prefix == "" {
|
||||
t.Fatalf("无法识别备份前缀: %s", b.Name)
|
||||
}
|
||||
if b.Reason != want[prefix] {
|
||||
t.Errorf("%s 的原因 = %q,期望 %q", b.Name, b.Reason, want[prefix])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非 NotExist 错误不能让选名函数死循环(它持锁会阻塞全部备份)。
|
||||
func TestUniqueBackupPathStopsOnError(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root 不受目录权限限制")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "locked")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatalf("创建目录: %v", err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o000); err != nil {
|
||||
t.Fatalf("改权限: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := uniqueBackupPath(dir, "event", "auto", time.Now())
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("目录不可读应返回错误")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("uniqueBackupPath 在非 NotExist 错误上死循环")
|
||||
}
|
||||
}
|
||||
|
||||
// 同一秒重复备份会带 -2 之类的序号,原因解析不能把它当成时间的一部分。
|
||||
func TestReasonFromNameWithSequence(t *testing.T) {
|
||||
cases := []struct{ name, prefix, want string }{
|
||||
|
||||
+120
-86
@@ -85,7 +85,13 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
|
||||
teams, err := loadTeams(db)
|
||||
// 排名输入只含启用单位,与 REST 一致。
|
||||
teams, err := loadTeams(db, true)
|
||||
if err != nil {
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
// 展示字段用全量单位表:停用但有流水的单位由 domain 兜底出行,名称不能是空的。
|
||||
allTeams, err := loadTeams(db, false)
|
||||
if err != nil {
|
||||
return StandingsWorkbook{}, err
|
||||
}
|
||||
@@ -107,24 +113,29 @@ func LoadStandingsWorkbook(db *sql.DB, opts Options) (StandingsWorkbook, error)
|
||||
Athletes: athletes,
|
||||
Ledger: ledger,
|
||||
Ranked: ranked,
|
||||
TieBreak: splitSettingList(settings["standings.tie_break"]),
|
||||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||||
ExcludeTeamEventMedals: strings.EqualFold(strings.TrimSpace(settings["standings.medal_counts_team_events"]), "false"),
|
||||
}
|
||||
return StandingsWorkbook{
|
||||
Meta: meta,
|
||||
Team: domain.BuildTeamStandings(in),
|
||||
Medal: domain.BuildMedalStandings(in),
|
||||
Athlete: domain.BuildAthleteStandings(in),
|
||||
}, nil
|
||||
team := domain.BuildTeamStandings(in)
|
||||
medal := domain.BuildMedalStandings(in)
|
||||
athlete := domain.BuildAthleteStandings(in)
|
||||
fillTeamDisplay(team, medal, athlete, allTeams)
|
||||
return StandingsWorkbook{Meta: meta, Team: team, Medal: medal, Athlete: athlete}, nil
|
||||
}
|
||||
|
||||
func loadTeams(db *sql.DB) ([]domain.Team, error) {
|
||||
// 只取启用中的单位;已软删除但仍有流水的单位由 domain 兜底补一行,分数不会被吞掉。
|
||||
rows, err := db.Query(`
|
||||
// loadTeams 读取单位表;activeOnly 为 true 时只取启用中的单位(排名输入口径)。
|
||||
func loadTeams(db *sql.DB, activeOnly bool) ([]domain.Team, error) {
|
||||
query := `
|
||||
SELECT id, code, name, COALESCE(short_name, ''), COALESCE(logo_path, ''),
|
||||
COALESCE(color, ''), sort_order, active
|
||||
FROM teams WHERE active = 1
|
||||
ORDER BY sort_order, code`)
|
||||
FROM teams`
|
||||
if activeOnly {
|
||||
query += ` WHERE active = 1`
|
||||
}
|
||||
query += ` ORDER BY sort_order, code`
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取单位: %w", err)
|
||||
}
|
||||
@@ -146,6 +157,66 @@ func loadTeams(db *sql.DB) ([]domain.Team, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fillTeamDisplay 用全量单位表补齐榜单行的展示字段。
|
||||
// 排名输入只含启用单位,停用但有流水的单位由 domain 兜底出行,编码/名称原本是空的。
|
||||
func fillTeamDisplay(team []domain.StandingsRow, medal []domain.MedalRow, athlete []domain.AthleteStandingsRow, teams []domain.Team) {
|
||||
byID := make(map[int64]domain.Team, len(teams))
|
||||
for _, t := range teams {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
for i := range team {
|
||||
if t, ok := byID[team[i].TeamID]; ok {
|
||||
if team[i].Code == "" {
|
||||
team[i].Code = t.Code
|
||||
}
|
||||
if team[i].Name == "" {
|
||||
team[i].Name = t.Name
|
||||
}
|
||||
if team[i].ShortName == "" {
|
||||
team[i].ShortName = t.ShortName
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range medal {
|
||||
if t, ok := byID[medal[i].TeamID]; ok {
|
||||
if medal[i].Code == "" {
|
||||
medal[i].Code = t.Code
|
||||
}
|
||||
if medal[i].Name == "" {
|
||||
medal[i].Name = t.Name
|
||||
}
|
||||
if medal[i].ShortName == "" {
|
||||
medal[i].ShortName = t.ShortName
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range athlete {
|
||||
if t, ok := byID[athlete[i].TeamID]; ok {
|
||||
if athlete[i].TeamCode == "" {
|
||||
athlete[i].TeamCode = t.Code
|
||||
}
|
||||
if athlete[i].TeamName == "" {
|
||||
athlete[i].TeamName = t.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// splitSettingList 解析逗号分隔的设置值(如 standings.tie_break),空值返回 nil。
|
||||
func splitSettingList(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func loadAthletes(db *sql.DB) ([]domain.Athlete, error) {
|
||||
// 停用的运动员也可能有历史积分,名字要能显示,所以这里不过滤 active。
|
||||
rows, err := db.Query(`
|
||||
@@ -201,24 +272,22 @@ func loadLedger(db *sql.DB) ([]domain.LedgerEntry, error) {
|
||||
}
|
||||
|
||||
// loadRanked 从成绩表重建名次明细,供 domain 统计奖牌。
|
||||
// 奖牌只看「名次 + 通道 scope」,积分以 score_ledger 为准,所以这里不需要
|
||||
// 重算分值,也就能保证与正式计分结果完全一致。
|
||||
// 与 REST 的 buildRanked、快照的 loadRanked 同源:一条成绩在每个计分通道下各产出一条明细,
|
||||
// 多通道去重交给 domain;只排名不计分(scored=0)的项目不产出奖牌。
|
||||
// 积分以 score_ledger 为准,这里只取名次,不需要重算分值。
|
||||
func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
scopes, err := loadChannelScopes(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := db.Query(`
|
||||
SELECT r.id, r.entry_id, r.event_id, en.category_id, en.team_id,
|
||||
COALESCE(en.athlete_id, 0), COALESCE(a.name, ''), r.rank, r.result_value
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events e ON e.id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0
|
||||
AND e.scored = 1 AND en.status = 'registered' AND r.status = 'ok'
|
||||
ORDER BY r.event_id, r.rank, r.id`)
|
||||
SELECT r.id, r.event_id, en.category_id, r.rank, r.result_value, r.status,
|
||||
en.id, en.team_id, en.athlete_id, COALESCE(a.name, ''), c.scope, c.id
|
||||
FROM results r
|
||||
JOIN entries en ON en.id = r.entry_id
|
||||
JOIN events ev ON ev.id = r.event_id
|
||||
JOIN event_score_channels c ON c.event_id = r.event_id
|
||||
LEFT JOIN athletes a ON a.id = en.athlete_id
|
||||
WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok'
|
||||
AND ev.scored = 1
|
||||
AND (c.scope = 'team' OR en.athlete_id IS NOT NULL)
|
||||
ORDER BY r.id, c.sort_order, c.id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取名次: %w", err)
|
||||
}
|
||||
@@ -227,32 +296,23 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
var out []domain.RankedResult
|
||||
for rows.Next() {
|
||||
var (
|
||||
rr domain.RankedResult
|
||||
resultValue sql.NullFloat64
|
||||
rr domain.RankedResult
|
||||
value sql.NullFloat64
|
||||
athleteID sql.NullInt64
|
||||
rank int
|
||||
)
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EntryID, &rr.EventID, &rr.CategoryID, &rr.TeamID,
|
||||
&rr.AthleteID, &rr.AthleteName, &rr.Rank, &resultValue); err != nil {
|
||||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描名次: %w", err)
|
||||
}
|
||||
if resultValue.Valid {
|
||||
v := resultValue.Float64
|
||||
rr.Rank = rank
|
||||
if value.Valid {
|
||||
v := value.Float64
|
||||
rr.ResultValue = &v
|
||||
}
|
||||
if rr.TeamID == 0 {
|
||||
continue
|
||||
}
|
||||
// 一个项目可能同时配个人通道与团体通道,同一成绩要在两个榜各记一枚,
|
||||
// 因此按 scope 展开成多条明细。
|
||||
for _, sc := range scopes[rr.EventID] {
|
||||
if sc == domain.ScopeAthlete && rr.AthleteID == 0 {
|
||||
continue
|
||||
}
|
||||
item := rr
|
||||
item.Scope = sc
|
||||
item.Basis = domain.BasisResult
|
||||
item.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, item)
|
||||
}
|
||||
rr.AthleteID = athleteID.Int64
|
||||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||||
out = append(out, rr)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历名次: %w", err)
|
||||
@@ -260,39 +320,6 @@ func loadRanked(db *sql.DB) ([]domain.RankedResult, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadChannelScopes(db *sql.DB) (map[int64][]domain.Scope, error) {
|
||||
rows, err := db.Query(`SELECT event_id, scope FROM event_score_channels ORDER BY event_id, sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("export: 读取计分通道: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
out := make(map[int64][]domain.Scope)
|
||||
for rows.Next() {
|
||||
var eventID int64
|
||||
var scope domain.Scope
|
||||
if err := rows.Scan(&eventID, &scope); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描计分通道: %w", err)
|
||||
}
|
||||
if !containsScope(out[eventID], scope) {
|
||||
out[eventID] = append(out[eventID], scope)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("export: 遍历计分通道: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func containsScope(list []domain.Scope, scope domain.Scope) bool {
|
||||
for _, s := range list {
|
||||
if s == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// eventInfo 是导出分项成绩表需要的项目信息。
|
||||
type eventInfo struct {
|
||||
id int64
|
||||
@@ -471,7 +498,7 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
|
||||
if db == nil {
|
||||
return TeamDetailWorkbook{}, ErrNoDatabase
|
||||
}
|
||||
meta, _, err := resolveMeta(db, opts.Meta)
|
||||
meta, settings, err := resolveMeta(db, opts.Meta)
|
||||
if err != nil {
|
||||
return TeamDetailWorkbook{}, err
|
||||
}
|
||||
@@ -483,7 +510,12 @@ func LoadTeamDetailWorkbook(db *sql.DB, teamID int64, opts Options) (TeamDetailW
|
||||
if err != nil {
|
||||
return TeamDetailWorkbook{}, err
|
||||
}
|
||||
return TeamDetailWorkbook{Meta: meta, Team: team, Rows: rows}, nil
|
||||
return TeamDetailWorkbook{
|
||||
Meta: meta,
|
||||
Team: team,
|
||||
Rows: rows,
|
||||
IndividualToTeamWeight: parseFloatDefault(settings["standings.individual_to_team_weight"], 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func loadTeam(db *sql.DB, teamID int64) (domain.Team, error) {
|
||||
@@ -508,7 +540,7 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ''),
|
||||
COALESCE(a.name, ''), COALESCE(r.rank, 0), COALESCE(r.result_text, ''),
|
||||
l.source_type, l.points, COALESCE(l.void_reason, '')
|
||||
l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0)
|
||||
FROM score_ledger l
|
||||
LEFT JOIN events e ON e.id = l.event_id
|
||||
LEFT JOIN sports s ON s.id = e.sport_id
|
||||
@@ -527,11 +559,13 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) {
|
||||
for rows.Next() {
|
||||
var r TeamDetailRow
|
||||
var source string
|
||||
var athleteID int64
|
||||
if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank,
|
||||
&r.ResultText, &source, &r.Points, &r.Remark); err != nil {
|
||||
&r.ResultText, &source, &r.Points, &r.Remark, &athleteID); err != nil {
|
||||
return nil, fmt.Errorf("export: 扫描积分明细: %w", err)
|
||||
}
|
||||
r.Source = sourceLabel(source)
|
||||
r.individual = athleteID != 0
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
|
||||
)
|
||||
|
||||
// 导出与 WS 快照必须同源:同一赛事库上,总积分榜的总分、奖牌与名次不能对不上。
|
||||
func TestExportStandingsMatchesSnapshot(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出总表: %v", err)
|
||||
}
|
||||
hub := realtime.NewHub(db, testLogger())
|
||||
defer func() { _ = hub.Close() }()
|
||||
snap, err := hub.Snapshot(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("读取 WS 快照: %v", err)
|
||||
}
|
||||
|
||||
if len(wb.Team) != len(snap.Standings) {
|
||||
t.Fatalf("单位数不一致:导出 %d,快照 %d", len(wb.Team), len(snap.Standings))
|
||||
}
|
||||
for _, s := range snap.Standings {
|
||||
idx := -1
|
||||
for i := range wb.Team {
|
||||
if wb.Team[i].TeamID == s.TeamID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
t.Fatalf("快照中的单位 %d 不在导出结果里", s.TeamID)
|
||||
}
|
||||
row := wb.Team[idx]
|
||||
if row.Points != s.TotalPoints || row.Gold != s.Gold || row.Silver != s.Silver ||
|
||||
row.Bronze != s.Bronze || row.Medals != s.MedalTotal || row.Rank != s.Rank {
|
||||
t.Errorf("单位 %d 口径不一致:导出 %+v,快照 %+v", s.TeamID, row, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只排名不计分(scored=0)的项目不进奖牌榜,即使它配了计分通道。
|
||||
func TestLoadRankedExcludesRankOnlyEvents(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
|
||||
stmts := []string{
|
||||
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, status)
|
||||
VALUES (1, 'E2', '飞行人员战斗体能', 'individual', 'auto', 'score', 'desc', 0, 'published')`,
|
||||
`INSERT INTO event_categories(event_id, name, sort_order) VALUES (2, '男子', 1)`,
|
||||
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id)
|
||||
SELECT 2, 'athlete', 'result', id FROM scoring_profiles WHERE code = 'individual_top6'`,
|
||||
`INSERT INTO entries(event_id, category_id, team_id, athlete_id, bib) VALUES (2, 2, 1, 1, '201')`,
|
||||
`INSERT INTO results(event_id, entry_id, rank, result_text, result_value, state)
|
||||
VALUES (2, 3, 1, '100', 100, 'published')`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
ranked, err := loadRanked(db)
|
||||
if err != nil {
|
||||
t.Fatalf("读取名次: %v", err)
|
||||
}
|
||||
for _, rr := range ranked {
|
||||
if rr.EventID == 2 {
|
||||
t.Fatalf("scored=0 的项目不应产出奖牌名次: %+v", rr)
|
||||
}
|
||||
}
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出总表: %v", err)
|
||||
}
|
||||
for _, row := range wb.Team {
|
||||
if row.TeamID == 1 && row.Gold != 1 {
|
||||
t.Errorf("T1 金牌 = %d,期望 1(只排名项目不得计牌)", row.Gold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 排序口径取自 settings.standings.tie_break,不再是硬编码默认序。
|
||||
func TestLoadStandingsHonoursTieBreak(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
// T1 金牌 1 但总分 5,T2 无牌但总分 10。
|
||||
for _, s := range []string{
|
||||
`UPDATE score_ledger SET points = 5 WHERE team_id = 1`,
|
||||
`UPDATE score_ledger SET points = 10 WHERE team_id = 2`,
|
||||
} {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("默认口径导出: %v", err)
|
||||
}
|
||||
if wb.Team[0].Code != "T2" {
|
||||
t.Fatalf("默认按总分排序,第一名 = %q,期望 T2", wb.Team[0].Code)
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`INSERT INTO settings(key, value) VALUES ('standings.tie_break', 'gold,points')
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value`); err != nil {
|
||||
t.Fatalf("写入 tie_break: %v", err)
|
||||
}
|
||||
wb, err = LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("金牌优先导出: %v", err)
|
||||
}
|
||||
if wb.Team[0].Code != "T1" {
|
||||
t.Errorf("tie_break=gold,points 时第一名 = %q,期望 T1", wb.Team[0].Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 停用但仍有积分的单位要显示编码/名称,不能是空行。
|
||||
func TestLoadStandingsShowsInactiveTeam(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
for _, s := range []string{
|
||||
`INSERT INTO teams(code, name, short_name, sort_order, active)
|
||||
VALUES ('T3', '第三分公司', '三公司', 3, 0)`,
|
||||
`INSERT INTO score_ledger(team_id, source_type, source_id, points)
|
||||
VALUES (3, 'adjustment', 99, 7)`,
|
||||
} {
|
||||
if _, err := db.Exec(s); err != nil {
|
||||
t.Fatalf("造数据 %q: %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
wb, err := LoadStandingsWorkbook(db, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("导出总表: %v", err)
|
||||
}
|
||||
idx := -1
|
||||
for i := range wb.Team {
|
||||
if wb.Team[i].TeamID == 3 {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
t.Fatalf("停用单位未出现在榜单:%+v", wb.Team)
|
||||
}
|
||||
row := wb.Team[idx]
|
||||
if row.Code != "T3" || row.Name != "第三分公司" || row.ShortName != "三公司" {
|
||||
t.Errorf("停用单位展示字段不完整: %+v", row)
|
||||
}
|
||||
if row.Points != 7 {
|
||||
t.Errorf("停用单位总分 = %v,期望 7", row.Points)
|
||||
}
|
||||
}
|
||||
|
||||
// 单位积分明细的「合计」与总积分榜同口径:个人分要乘 individual_to_team_weight。
|
||||
func TestTeamDetailTotalAppliesWeight(t *testing.T) {
|
||||
db := openEventDB(t)
|
||||
seedEvent(t, db)
|
||||
if _, err := db.Exec(`UPDATE settings SET value = '2' WHERE key = 'standings.individual_to_team_weight'`); err != nil {
|
||||
t.Fatalf("修改折算系数: %v", err)
|
||||
}
|
||||
|
||||
wb, err := LoadTeamDetailWorkbook(db, 1, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("读取积分明细: %v", err)
|
||||
}
|
||||
if wb.IndividualToTeamWeight != 2 {
|
||||
t.Fatalf("折算系数 = %v,期望 2", wb.IndividualToTeamWeight)
|
||||
}
|
||||
rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight)
|
||||
if len(rows) < 2 {
|
||||
t.Fatalf("明细行数 = %d,期望含合计行", len(rows))
|
||||
}
|
||||
// 与 REST 一致:个人分逐行折算,合计等于各行之和。
|
||||
if got, ok := rows[0][8].(float64); !ok || got != 20 {
|
||||
t.Errorf("明细行积分 = %v,期望 20(10 × 系数 2)", rows[0][8])
|
||||
}
|
||||
total, ok := rows[len(rows)-1][8].(float64)
|
||||
if !ok {
|
||||
t.Fatalf("合计列类型异常: %T", rows[len(rows)-1][8])
|
||||
}
|
||||
if total != 20 {
|
||||
t.Errorf("合计 = %v,期望 20(10 × 系数 2)", total)
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,12 @@ const (
|
||||
projectFormatVersion = 1
|
||||
// eventDBName 是包内赛事库的固定文件名。
|
||||
eventDBName = "event.db"
|
||||
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
|
||||
maxExtractBytes = 4 << 30
|
||||
)
|
||||
|
||||
// maxExtractBytes 限制解压总体积,避免恶意包把磁盘塞满。
|
||||
// 定义为变量便于测试用较小的上限验证限流逻辑。
|
||||
var maxExtractBytes int64 = 4 << 30
|
||||
|
||||
// ProjectFile 描述包内一个文件的校验信息。
|
||||
type ProjectFile struct {
|
||||
Path string `yaml:"path"`
|
||||
@@ -253,18 +255,8 @@ func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, e
|
||||
}
|
||||
|
||||
target := filepath.Join(projRoot, name)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
if !opts.Overwrite {
|
||||
return ImportResult{}, fmt.Errorf("%w: %s", ErrProjectExists, name)
|
||||
}
|
||||
stale := target + ".replaced-" + time.Now().Format("20060102-150405")
|
||||
if err := os.Rename(target, stale); err != nil {
|
||||
return ImportResult{}, fmt.Errorf("export: 暂存旧项目 %s: %w", name, err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(stale) }()
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
return ImportResult{}, fmt.Errorf("export: 落盘赛事项目 %s: %w", name, err)
|
||||
if err := swapProjectDir(tmp, target, opts.Overwrite, os.Rename); err != nil {
|
||||
return ImportResult{}, err
|
||||
}
|
||||
return ImportResult{
|
||||
Name: name,
|
||||
@@ -274,6 +266,38 @@ func ImportProjectZip(src, projRoot string, opts ImportOptions) (ImportResult, e
|
||||
}, 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)
|
||||
@@ -289,6 +313,10 @@ func SafeProjectName(name string) (string, error) {
|
||||
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)
|
||||
@@ -511,7 +539,7 @@ func extractZip(src, dst string) error {
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
var total uint64
|
||||
var total int64
|
||||
for _, f := range r.File {
|
||||
target, err := secureJoin(dst, f.Name)
|
||||
if err != nil {
|
||||
@@ -523,8 +551,8 @@ func extractZip(src, dst string) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
total += f.UncompressedSize64
|
||||
if total > maxExtractBytes {
|
||||
// 声明值先卡一道,避免一上来就落盘;注意先判上界再转 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 {
|
||||
@@ -539,12 +567,17 @@ func extractZip(src, dst string) error {
|
||||
_ = rc.Close()
|
||||
return fmt.Errorf("export: 写入 %s: %w", target, err)
|
||||
}
|
||||
_, copyErr := io.Copy(out, rc)
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -147,8 +147,98 @@ func TestImportRejectsBadPackages(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// 覆盖导入时第二次改名失败,旧项目必须回滚回来,不能出现「旧项目已删、新项目没成」。
|
||||
func TestSwapProjectDirRollback(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "赛事")
|
||||
src := filepath.Join(root, ".import-1")
|
||||
writeFile(t, filepath.Join(target, "old.txt"), "旧")
|
||||
writeFile(t, filepath.Join(src, "new.txt"), "新")
|
||||
|
||||
calls := 0
|
||||
rename := func(old, new string) error {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return errors.New("模拟第二次改名失败")
|
||||
}
|
||||
return os.Rename(old, new)
|
||||
}
|
||||
if err := swapProjectDir(src, target, true, rename); err == nil {
|
||||
t.Fatal("第二次改名失败应返回错误")
|
||||
}
|
||||
if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" {
|
||||
t.Errorf("回滚后旧项目内容 = %q,期望 旧", got)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(src, "new.txt")); got != "新" {
|
||||
t.Errorf("新项目内容 = %q,期望 新", got)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("读取项目根目录: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), ".replaced-") {
|
||||
t.Errorf("回滚后残留暂存目录 %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不允许覆盖时必须原地报错,不能动旧项目。
|
||||
func TestSwapProjectDirNoOverwrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "赛事")
|
||||
src := filepath.Join(root, ".import-1")
|
||||
writeFile(t, filepath.Join(target, "old.txt"), "旧")
|
||||
writeFile(t, filepath.Join(src, "new.txt"), "新")
|
||||
|
||||
if err := swapProjectDir(src, target, false, os.Rename); !errors.Is(err, ErrProjectExists) {
|
||||
t.Fatalf("期望 ErrProjectExists,实际 %v", err)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(target, "old.txt")); got != "旧" {
|
||||
t.Errorf("拒绝覆盖后旧项目内容 = %q,期望 旧", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖成功后旧目录必须清理干净,不留 .replaced- 残留。
|
||||
func TestSwapProjectDirOverwriteCleansStale(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "赛事")
|
||||
src := filepath.Join(root, ".import-1")
|
||||
writeFile(t, filepath.Join(target, "old.txt"), "旧")
|
||||
writeFile(t, filepath.Join(src, "new.txt"), "新")
|
||||
|
||||
if err := swapProjectDir(src, target, true, os.Rename); err != nil {
|
||||
t.Fatalf("覆盖导入: %v", err)
|
||||
}
|
||||
if got := readFile(t, filepath.Join(target, "new.txt")); got != "新" {
|
||||
t.Errorf("覆盖后新项目内容 = %q,期望 新", got)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatalf("读取项目根目录: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.Contains(e.Name(), ".replaced-") {
|
||||
t.Errorf("覆盖成功后残留暂存目录 %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解压总体积上限:超过上限的包必须拒绝,不能把磁盘写满。
|
||||
func TestExtractZipRejectsOversize(t *testing.T) {
|
||||
old := maxExtractBytes
|
||||
maxExtractBytes = 64
|
||||
t.Cleanup(func() { maxExtractBytes = old })
|
||||
|
||||
p := filepath.Join(t.TempDir(), "big.zip")
|
||||
writeZip(t, p, map[string]string{"event.db": strings.Repeat("x", 200)})
|
||||
if err := extractZip(p, t.TempDir()); !errors.Is(err, ErrInvalidPackage) {
|
||||
t.Fatalf("期望 ErrInvalidPackage,实际 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeProjectName(t *testing.T) {
|
||||
bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y"}
|
||||
bad := []string{"", "..", "../evil", "a/b", `a\b`, "CON", "lpt1.db", "a:b", "x?y", "赛事.", "a.."}
|
||||
for _, name := range bad {
|
||||
if _, err := SafeProjectName(name); err == nil {
|
||||
t.Errorf("SafeProjectName(%q) 应报错", name)
|
||||
@@ -161,6 +251,25 @@ func TestSafeProjectName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("创建目录 %s: %v", filepath.Dir(path), err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatalf("写入 %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("读取 %s: %v", path, err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func assertTeams(t *testing.T, path string, want int) {
|
||||
t.Helper()
|
||||
db := openSQLite(t, path)
|
||||
|
||||
@@ -21,6 +21,8 @@ type TeamDetailRow struct {
|
||||
Source string
|
||||
Points float64
|
||||
Remark string
|
||||
// individual 标记该条流水记到个人:合计要按 individual_to_team_weight 折算。
|
||||
individual bool
|
||||
}
|
||||
|
||||
// TeamDetailWorkbook 是「单位积分明细」的内容。
|
||||
@@ -28,6 +30,8 @@ type TeamDetailWorkbook struct {
|
||||
Meta Meta
|
||||
Team domain.Team
|
||||
Rows []TeamDetailRow
|
||||
// IndividualToTeamWeight 与总积分榜同源,用于把个人分折算进「合计」。
|
||||
IndividualToTeamWeight float64
|
||||
}
|
||||
|
||||
var teamDetailColumns = []column{
|
||||
@@ -73,22 +77,31 @@ func teamDetailFile(wb TeamDetailWorkbook) (*excelize.File, error) {
|
||||
return err
|
||||
}
|
||||
title := sheetTitle(meta, displayName(wb.Team.Name, wb.Team.ShortName)+" · 积分明细")
|
||||
return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows))
|
||||
return writeSheet(f, st, name, meta, title, teamDetailColumns, teamDetailRows(wb.Rows, wb.IndividualToTeamWeight))
|
||||
})
|
||||
}
|
||||
|
||||
func teamDetailRows(rows []TeamDetailRow) [][]any {
|
||||
// teamDetailRows 把明细行排成表格。与 REST 的单位积分明细同口径:
|
||||
// 记到个人的流水按 individual_to_team_weight 折算后展示,合计即各行之和。
|
||||
func teamDetailRows(rows []TeamDetailRow, weight float64) [][]any {
|
||||
if weight <= 0 {
|
||||
weight = 1
|
||||
}
|
||||
out := make([][]any, 0, len(rows)+1)
|
||||
var total float64
|
||||
for i, r := range rows {
|
||||
total += r.Points
|
||||
points := r.Points
|
||||
if r.individual {
|
||||
points *= weight
|
||||
}
|
||||
total += points
|
||||
rank := ""
|
||||
if r.Rank > 0 {
|
||||
rank = strconv.Itoa(r.Rank)
|
||||
}
|
||||
out = append(out, []any{
|
||||
i + 1, r.SportName, r.EventName, r.Category, r.AthleteName,
|
||||
rank, r.ResultText, r.Source, r.Points, r.Remark,
|
||||
rank, r.ResultText, r.Source, points, r.Remark,
|
||||
})
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
|
||||
Reference in new issue
Block a user