diff --git a/deploy/prepare-signature.mjs b/deploy/prepare-signature.mjs index d643aaa..f2519bf 100644 --- a/deploy/prepare-signature.mjs +++ b/deploy/prepare-signature.mjs @@ -4,20 +4,23 @@ // 输出再经 cmd(chcp 65001)解码一次,中文会被转成乱码——曾导致日志写着 // "internal signature injected",但 exe 里的字节和预期对不上。 // 用 Node 读 UTF-8 再写回,同时剥掉可能的 BOM 与首尾空白,保证注入值稳定。 -import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' const root = dirname(import.meta.dirname) const src = join(root, 'deploy', 'signature.local.txt') const dst = join(root, 'deploy', 'signature.build.txt') +// 没有源署名时要清掉上次构建留下的产物,否则删掉署名文件后仍会注入旧值并通过校验。 if (!existsSync(src)) { + rmSync(dst, { force: true }) console.log(' no internal signature file') process.exit(0) } const first = readFileSync(src, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/, 1)[0].trim() if (first === '') { + rmSync(dst, { force: true }) console.log(' internal signature file is empty') process.exit(0) } diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 59e773b..faabb7c 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -25,7 +25,7 @@ - [ ] 双击 `Scoreboard Console.exe`,启动管理窗口出现 - [ ] 服务端状态变为「运行中 · 就绪」 - [ ] 打开管理面板(窗口内「打开服务端管理面板」按钮),用管理员账号登录 -- [ ] **管理员初始密码**在 `logs\console.log` 里搜 `password=`;首次登录会要求改密 +- [ ] **管理员初始密码**在 `logs\console.log` 里搜 `INITIAL ADMIN CREATED`,密码就在该行上方;也可以直接打开 Console 的「运行日志」面板往上翻。首次登录会要求改密 ## 4. 赛事配置(管理员) diff --git a/docs/部署指南.md b/docs/部署指南.md index 3803e3a..060d01e 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -137,29 +137,21 @@ deploy\build.bat ### 5.3 从日志里找初始密码 -用 Console 启动时,服务端输出会同时显示在「运行日志」面板并写入 `logs\console.log`。初始密码所在的行形如: +用 Console 启动时,服务端输出会同时显示在「运行日志」面板并写入 `logs\console.log`。初始密码所在的一段输出形如: ``` -[server] 初始密码: xxxxxxxxxxxxxxxx +[server] 初始密码: xxxxxxxxxxxxxxxx +[server] 首次登录后请立即修改密码 +[server] === INITIAL ADMIN CREATED (password above) === ``` -在部署目录下执行以下任一命令即可找到(推荐 PowerShell,避免中文编码问题): - -```powershell -Select-String -Path .\logs\console.log -Pattern '初始密码' -``` +在部署目录下执行以下命令即可找到(搜索纯 ASCII 标记行,中文 `findstr` 在 chcp 936/65001 下不可靠): ```bat -findstr /C:"初始密码" logs\console.log +findstr /C:"INITIAL ADMIN CREATED" logs\console.log ``` -如果中文匹配不到,改用 ASCII 关键字(服务端还会输出一条 `password=` 的日志): - -```bat -findstr /C:"password=" logs\console.log -``` - -也可以直接在 Console 窗口的**运行日志**里查找「初始密码」或「已创建默认管理员」。 +搜到这一行后,初始密码就在它上面几行(形如 `初始密码: xxxxxxxx`)。也可以直接打开 Console 的「运行日志」面板往上翻。 > 如果不用 Console、直接双击 `scoreboard.exe`,密码只会打印在终端窗口里,不会落文件。建议始终通过 Console 启动。 diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index eedf909..66c2334 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -364,13 +364,15 @@ func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlet } // buildRanked 从已发布成绩重建名次明细(用于奖牌统计与个人榜)。 -// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致。 +// 一条成绩在每个通道下各产出一条明细,与计分引擎的口径一致; +// 只排名不计分(scored=0)的项目不产出奖牌,必须与导出侧同口径。 func (s *server) buildRanked(ctx context.Context, q queryer) ([]domain.RankedResult, error) { rows, err := q.QueryContext(ctx, `SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status, e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id FROM results r JOIN entries e ON e.id = r.entry_id + JOIN events ev ON ev.id = r.event_id AND ev.scored = 1 JOIN event_score_channels c ON c.event_id = r.event_id LEFT JOIN athletes a ON a.id = e.athlete_id WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok' diff --git a/server/internal/httpapi/repo_misc_test.go b/server/internal/httpapi/repo_misc_test.go index 4fd20be..ea49878 100644 --- a/server/internal/httpapi/repo_misc_test.go +++ b/server/internal/httpapi/repo_misc_test.go @@ -162,6 +162,41 @@ func TestTeamBreakdownAppliesIndividualWeight(t *testing.T) { } } +// 只排名不计分(scored=0)的项目即使配了计分通道,也不得在大屏奖牌榜产出奖牌; +// 导出侧已按 ev.scored = 1 过滤,两边口径必须一致,否则奖牌榜与成绩册对不上。 +func TestStandingsExcludesUnscoredEventMedals(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + unscored := 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', 0, 1, 'ongoing')`, ids.sport) + cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, unscored) + var profileID int64 + if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'individual_top6'`).Scan(&profileID); err != nil { + t.Fatalf("读取积分模板: %v", err) + } + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'athlete', 'result', ?, 0)`, unscored, profileID) + entry := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, unscored, cat, ids.teamB, ids.athB) + env.exec(t, `INSERT INTO results (event_id, entry_id, rank, result_value, status, state) + VALUES (?, ?, 1, 22.10, 'ok', 'published')`, unscored, entry) + + _, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false) + standings := decodeData[[]map[string]any](t, env2) + for _, row := range standings { + if row["teamCode"] == "B" { + if row["gold"].(float64) != 0 || row["medalTotal"].(float64) != 0 { + t.Fatalf("scored=0 的项目不应计入奖牌榜: %+v", row) + } + } + } +} + // standings.tie_break 必须真正参与排序,否则换规则包后名次仍走硬编码默认序。 func TestStandingsTieBreakFromSettings(t *testing.T) { env := newTestEnv(t) diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index 51f0786..b019b36 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -330,15 +330,15 @@ func computeEventScore(ctx context.Context, q queryer, eventID, categoryID int64 // 作废范围是引擎产出的成绩分与破纪录加分;人工调整由 point_adjustments 管理,绝不在此作废。 // 聚合通道的流水 entry_id/result_id 都为空(名次主体是单位/运动员,不对应单条报名项), // 所以按组别重算时不能只靠 entry_id IN (...) 定位,否则 NULL 永不命中、旧流水存活, -// 重算再插一份导致该通道分数翻倍。这类流水改按「本小项的聚合通道」定位。 +// 重算再插一份导致该通道分数翻倍。聚合流水本身不归属组别,且重建通道会换掉通道 id +// (source_id 随之失效),故按 event_id 全量作废;下方补算整项聚合流水时会重新插入全量。 func recomputeLedgerTx(ctx context.Context, tx *sql.Tx, eventID, categoryID, actorID int64, reason string) (domain.EventScore, error) { scope := `source_type IN ('result', 'record_bonus') AND event_id = ?` args := []any{eventID} if categoryID > 0 { scope += ` AND (entry_id IN (SELECT id FROM entries WHERE category_id = ?) - OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result' - AND source_id IN (SELECT id FROM event_score_channels WHERE event_id = ?)))` - args = append(args, categoryID, eventID) + OR (entry_id IS NULL AND result_id IS NULL AND source_type = 'result'))` + args = append(args, categoryID) } if _, err := tx.ExecContext(ctx, `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? diff --git a/server/internal/httpapi/scoring_test.go b/server/internal/httpapi/scoring_test.go index 8228a55..f1cf6ec 100644 --- a/server/internal/httpapi/scoring_test.go +++ b/server/internal/httpapi/scoring_test.go @@ -68,6 +68,59 @@ func TestRecomputeAggregateLedgerIsIdempotent(t *testing.T) { publish("再次按组别发布", &catID) } +// 整体替换通道会 DELETE + INSERT 生成新的通道 id,旧聚合流水的 source_id 随之失效。 +// 若作废范围仍按 source_id IN (本小项通道) 定位,旧流水作废不掉、重算再插一份 → 分数翻倍。 +func TestRecomputeAggregateLedgerSurvivesChannelRebuild(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + var teamProfile int64 + if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil { + t.Fatalf("读取团体积分模板: %v", err) + } + env.exec(t, `DELETE FROM event_score_channels WHERE event_id = ?`, ids.event) + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'team', 'aggregate', ?, 0)`, ids.event, teamProfile) + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "unit": "s", "status": "ok"}, + }, + }, true) + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", + map[string]any{"eventId": ids.event}, true); status != http.StatusOK { + t.Fatalf("整项发布应 200,得到 %d %+v", status, env2.Error) + } + // 聚合通道两单位各一条流水:15 + 10。 + if count, total := env.resultLedger(t); count != 2 || total != 25 { + t.Fatalf("整项发布后成绩流水应为 2 条 25 分,得到 %d 条 %v 分", count, total) + } + + // 走真实接口整体替换通道,模拟运维改配置(通道 id 会被重建)。 + status, env2 := env.do(client, http.MethodPut, fmt.Sprintf("/api/events/%d/channels", ids.event), + map[string]any{"channels": []map[string]any{ + {"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 0}, + }}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("替换通道应 200,得到 %d %+v", status, env2.Error) + } + + catID := ids.category + status, env2 = env.do(client, http.MethodPost, "/api/results/publish", + map[string]any{"eventId": ids.event, "categoryId": catID}, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("按组别重算应 200,得到 %d %+v", status, env2.Error) + } + if count, total := env.resultLedger(t); count != 2 || total != 25 { + t.Fatalf("改通道后按组别重算不得翻倍,应仍为 2 条 25 分,得到 %d 条 %v 分", count, total) + } +} + // 作废一条成绩会改变同组别名次,重算结果必须回写 results.rank, // 否则奖牌榜(读 rank)与积分榜(读流水)互相矛盾。 func TestVoidResultRewritesRemainingRanks(t *testing.T) { diff --git a/server/internal/realtime/hub.go b/server/internal/realtime/hub.go index 4800ab4..3d1549e 100644 --- a/server/internal/realtime/hub.go +++ b/server/internal/realtime/hub.go @@ -25,6 +25,10 @@ const ( defaultSendBuffer = 64 ) +// slowCloseCode 是踢掉慢客户端时使用的自定义关闭码。 +// 不能用 1008:前端把所有 1008 当「登录失效」并永久停止重连,慢客户端被踢后大屏会冻结。 +const slowCloseCode = websocket.StatusCode(4000) + // Hub 是实时同步中心:维护房间、连接、大屏实例注册表与版本号。 // // 版本号等于 change_log 的最大 id;写入方提交事务后调用 Broadcast 推送变更, @@ -568,7 +572,7 @@ func (h *Hub) markSlow(c *client) { h.log.Warn("慢客户端:ack 长时间落后,断开后由重连补齐", "instance", id, "ackVersion", c.ackVersion.Load(), "remote", c.remoteAddr) - c.stop(websocket.StatusPolicyViolation, "ack 落后") + c.stop(slowCloseCode, "ack 落后") } // heartbeatLoop 每 10s 下发一次心跳(version + serverTime)。 diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go index f893ac0..9796185 100644 --- a/server/internal/realtime/hub_test.go +++ b/server/internal/realtime/hub_test.go @@ -221,6 +221,30 @@ func (c *wsClient) expectClosedWithCode(d time.Duration, want websocket.StatusCo } } +// expectClosedWithCodeDraining 断言连接最终被服务端以指定关闭码关闭。 +// 关闭前已下发的正常事件会被丢弃:慢客户端被踢属于「先推后关」,不能要求关闭前零事件。 +func (c *wsClient) expectClosedWithCodeDraining(d time.Duration, want websocket.StatusCode) { + c.t.Helper() + timer := time.NewTimer(d) + defer timer.Stop() + for { + select { + case msg, ok := <-c.msgs: + if !ok { + c.t.Fatalf("连接已关闭但没有收到关闭帧") + } + if msg.err != nil { + if msg.closeCode != want { + c.t.Fatalf("关闭码应为 %d,得到 %d (%v)", want, msg.closeCode, msg.err) + } + return + } + case <-timer.C: + c.t.Fatalf("等待服务端关闭连接超时") + } + } +} + func decode[T any](t *testing.T, raw json.RawMessage) T { t.Helper() var v T @@ -601,6 +625,8 @@ func TestSlowClientDetected(t *testing.T) { insts := env.hub.Instances() return len(insts) == 1 && !insts[0].Online }) + // 必须用自定义关闭码:前端把所有 1008 当登录失效并永久停重连,大屏会就此冻结。 + c.expectClosedWithCodeDraining(3*time.Second, slowCloseCode) } // 快照对所有角色下发,但系统配置是 admin-only:guest/recorder 只能拿到大屏渲染 diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go index bbdd336..7c950b4 100644 --- a/server/internal/realtime/snapshot.go +++ b/server/internal/realtime/snapshot.go @@ -282,13 +282,15 @@ func loadLedger(ctx context.Context, tx *sql.Tx) ([]domain.LedgerEntry, error) { } // loadRanked 与 REST 的 buildRanked 同源:必须 join event_score_channels, -// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌。 +// 否则多通道项目会把一枚奖牌算成多枚,没配通道的项目也会凭空产生奖牌; +// 同时排除只排名不计分(scored=0)的项目,与导出侧口径一致。 func loadRanked(ctx context.Context, tx *sql.Tx) ([]domain.RankedResult, error) { rows, err := tx.QueryContext(ctx, ` SELECT r.id, r.event_id, e.category_id, r.rank, r.result_value, r.status, e.id, e.team_id, e.athlete_id, COALESCE(a.name, ''), c.scope, c.id FROM results r JOIN entries e ON e.id = r.entry_id + JOIN events ev ON ev.id = r.event_id AND ev.scored = 1 JOIN event_score_channels c ON c.event_id = r.event_id LEFT JOIN athletes a ON a.id = e.athlete_id WHERE r.state = 'published' AND r.rank IS NOT NULL AND r.rank > 0 AND r.status = 'ok' diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go index 040564a..0b0185e 100644 --- a/server/internal/realtime/snapshot_test.go +++ b/server/internal/realtime/snapshot_test.go @@ -338,6 +338,28 @@ func TestSnapshotStandingsOnlyScoredChannels(t *testing.T) { } } +// 只排名不计分(scored=0)的项目即使配了计分通道也不计入快照奖牌榜,与导出侧同口径。 +func TestSnapshotStandingsExcludesUnscoredEvents(t *testing.T) { + env := newTestEnv(t) + db := env.db + + team1 := insertTeam(t, db, "T1", "第一单位", "") + event1 := insertEvent(t, db, "E1", "表演项目") + mustExec(t, db, `UPDATE events SET scored = 0 WHERE id = ?`, event1) + cat1 := insertCategory(t, db, event1, "男子") + entry1 := insertEntry(t, db, event1, cat1, team1, 0) + insertChannel(t, db, event1, "team") + insertResult(t, db, event1, entry1, 1, "9.99", 9.99, "2026-09-09T10:00:00Z") + + snap, err := env.hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("构建快照: %v", err) + } + if len(snap.Standings) != 1 || snap.Standings[0].Gold != 0 || snap.Standings[0].MedalTotal != 0 { + t.Fatalf("scored=0 的项目不应产出奖牌: %+v", snap.Standings) + } +} + func TestEmptySnapshotMarshalsEmptyLists(t *testing.T) { env := newTestEnv(t) snap, err := env.hub.Snapshot(context.Background()) diff --git a/server/main.go b/server/main.go index 50f77d4..d4f4225 100644 --- a/server/main.go +++ b/server/main.go @@ -104,11 +104,7 @@ func main() { logger.Error("初始化默认管理员失败", "err", err) os.Exit(1) } else if created { - // 初始密码只打印这一次;数据库里只有 bcrypt 哈希。 - // 结构化日志会被采集/落盘,绝不写入密码,只提示密码已在上方一次性输出。 - fmt.Printf("\n========================================\n"+ - " 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+ - "========================================\n\n", username, password) + fmt.Print(initialAdminBanner(username, password)) logger.Warn("已创建默认管理员,初始密码仅在上方一次性输出,不写入日志", "username", username) } @@ -156,6 +152,15 @@ func main() { } } +// initialAdminBanner 组装初始管理员提示块:初始密码只在这里一次性输出,不写入结构化日志。 +// 末尾的纯 ASCII 锚供现场在 logs\console.log 里检索——中文 findstr 在 chcp 936/65001 下不可靠。 +func initialAdminBanner(username, password string) string { + return fmt.Sprintf("\n========================================\n"+ + " 已创建默认管理员\n 用户名: %s\n 初始密码: %s\n 首次登录后请立即修改密码\n"+ + "=== INITIAL ADMIN CREATED (password above) ===\n"+ + "========================================\n\n", username, password) +} + // displaySender 把 httpapi 的大屏命令接口适配到实时层。 type displaySender struct{ hub *realtime.Hub } diff --git a/server/main_test.go b/server/main_test.go index 187cb8a..1da2aa1 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -47,3 +47,19 @@ func TestRunBackupUsesEventPrefix(t *testing.T) { t.Errorf("未生成 event- 前缀的赛事库备份,实际文件: %v", entries) } } + +// 现场按文档用 findstr 搜纯 ASCII 锚找初始密码;中文匹配在 chcp 936/65001 下不可靠。 +func TestInitialAdminBannerAsciiAnchor(t *testing.T) { + const anchor = "=== INITIAL ADMIN CREATED (password above) ===" + banner := initialAdminBanner("admin", "s3cret-pass") + if !strings.Contains(banner, anchor) { + t.Fatalf("banner 缺少可搜索的 ASCII 锚 %q:\n%s", anchor, banner) + } + if !strings.Contains(banner, "s3cret-pass") { + t.Fatalf("banner 应包含初始密码:\n%s", banner) + } + // 锚在密码之后,文案 password above 才成立。 + if strings.Index(banner, "s3cret-pass") > strings.Index(banner, anchor) { + t.Fatalf("ASCII 锚应在密码行之后:\n%s", banner) + } +} diff --git a/web/src/admin/pages/HistoryPage.vue b/web/src/admin/pages/HistoryPage.vue index 060cab8..7a709da 100644 --- a/web/src/admin/pages/HistoryPage.vue +++ b/web/src/admin/pages/HistoryPage.vue @@ -47,7 +47,11 @@ const detailLoadedIds = ref([]) const detailLoading = ref(false) const detailError = ref(null) -const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1) +/** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */ +const weight = computed(() => { + const w = data.settings?.standings.individualToTeamWeight ?? 1 + return w > 0 ? w : 1 +}) const visibleRevisions = computed(() => { const threshold = timeThreshold(timeFilter.value) diff --git a/web/src/admin/pages/ReviewPage.vue b/web/src/admin/pages/ReviewPage.vue index 985a33c..1ad1be8 100644 --- a/web/src/admin/pages/ReviewPage.vue +++ b/web/src/admin/pages/ReviewPage.vue @@ -72,7 +72,11 @@ const returnTarget = ref(null) const returnedKeys = ref([]) const canWrite = computed(() => !auth.readOnly && data.canWrite) -const weight = computed(() => data.settings?.standings.individualToTeamWeight ?? 1) +/** 非 admin 会话的快照会把折算系数清零(dto.WithoutAdminFields),0 兜底成 1,否则个人项目折算分全变 0。 */ +const weight = computed(() => { + const w = data.settings?.standings.individualToTeamWeight ?? 1 + return w > 0 ? w : 1 +}) const filteredGroups = computed(() => { const keyword = search.value.trim()