Files
Scoreboard/server/internal/httpapi/entries_batch_test.go
T
AskaEth 631c8dd8e1 feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:

① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
   entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
   名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
   奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
   「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
   改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
   权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
   dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
   跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
   ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
   退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
   (四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
   行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
   Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
   同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)

其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。

测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
2026-09-11 17:14:02 +08:00

412 lines
18 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 httpapi_test
import (
"database/sql"
"errors"
"fmt"
"net/http"
"strings"
"testing"
"time"
)
// batchItem / batchSummary / batchResult 是 POST /api/entries/batch 的响应契约,
// 用例直接按前端会收到的 JSON 字段来断言,避免服务端字段改名后测试还绿着。
type batchItem struct {
ID int64 `json:"id"`
OK bool `json:"ok"`
Error string `json:"error"`
Skipped bool `json:"skipped"`
Note string `json:"note"`
}
type batchSummary struct {
OK int `json:"ok"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Changed int `json:"changed"`
RemovedEntries int `json:"removedEntries"`
RemovedResults int `json:"removedResults"`
RemovedLedger int `json:"removedLedger"`
RemovedBreaks int `json:"removedBreaks"`
RecomputedEvent []int64 `json:"recomputedEvents"`
}
type batchResult struct {
Items []batchItem `json:"items"`
Summary batchSummary `json:"summary"`
Raw map[string]any `json:"-"`
}
// entryStatus 读报名项当前状态。
func entryStatus(t *testing.T, env *testEnv, entryID int64) string {
t.Helper()
var status string
if err := env.ev.QueryRow(`SELECT status FROM entries WHERE id = ?`, entryID).Scan(&status); err != nil {
t.Fatalf("读报名项状态: %v", err)
}
return status
}
// addTeamAthleteEntry 在既有小项 / 组别下补一个单位 + 运动员 + 报名项。
func addTeamAthleteEntry(t *testing.T, env *testEnv, ids seedIDs, code, name string, athleteName string) (int64, int64) {
t.Helper()
teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, 9, 1)`,
code, name, name)
athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`,
athleteName, teamID)
entryID := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, teamID, athleteID)
return teamID, entryID
}
// 批量退赛后别人被重算、被退赛的人不再占名次;恢复后榜单回到原状。
func TestEntryBatchScratchAndRestore(t *testing.T) {
env, ids, client := recorderWithSeed(t)
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
publishResults(t, env, client, ids.event, "batch",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
)
// 前置:10 / 8 / 6 分。
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
t.Fatalf("前置条件:B 应是 8 分,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 6 {
t.Fatalf("前置条件:C 单位总分应是 6,得到 %v", got)
}
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryB, entryC}, "reason": "临时请假",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量退赛应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if len(result.Items) != 2 || !result.Items[0].OK || !result.Items[1].OK {
t.Fatalf("应有 2 条逐条成功,得到 %+v", result.Items)
}
if result.Summary.OK != 2 || result.Summary.Failed != 0 || result.Summary.Changed != 2 {
t.Fatalf("汇总应是 2 成功 / 0 失败,得到 %+v", result.Summary)
}
if got := entryStatus(t, env, ids.entryB); got != "scratched" {
t.Fatalf("B 应已退赛,得到 %s", got)
}
if got := entryStatus(t, env, entryC); got != "scratched" {
t.Fatalf("C 应已退赛,得到 %s", got)
}
// 退赛的行不再有有效流水,旧流水保留为「已作废」的证据。
if got := activeLedgerPoints(t, env, ids.entryB); len(got) != 0 {
t.Fatalf("B 退赛后不应再有有效流水,得到 %v", got)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ? AND voided = 1`, ids.entryB); n != 1 {
t.Fatalf("B 的旧流水应被作废,得到 %d 条", n)
}
// 别人被重算:A 仍是第一 10 分,C 退赛不占名次。
if got := activeLedgerPoints(t, env, ids.entryA); !equalFloats(got, []float64{10}) {
t.Fatalf("A 应保持 10 分,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 0 {
t.Fatalf("C 单位总分应归零,得到 %v", got)
}
// 退赛不占名次:已发布成绩的 rank 必须清掉,否则奖牌统计还会给他记一块金牌。
for _, id := range []int64{ids.entryB, entryC} {
var rank sql.NullInt64
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, id).Scan(&rank); err != nil {
t.Fatalf("读退赛行的名次: %v", err)
}
if rank.Valid {
t.Fatalf("退赛的报名项不应再占名次,entry %d 的 rank=%d", id, rank.Int64)
}
}
if n := countRows(t, env,
`SELECT COUNT(*) FROM results r JOIN entries e ON e.id = r.entry_id
WHERE r.state = 'published' AND r.rank IS NOT NULL AND e.status <> 'registered'`); n != 0 {
t.Fatalf("非正常参赛的已发布成绩不该带名次,得到 %d 条", n)
}
// 恢复:同一端点 restore,只有 scratched 会被改回 registered。
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "restore", "ids": []int64{ids.entryB, entryC},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("批量恢复应 200,得到 %d %+v", status, env2.Error)
}
if got := entryStatus(t, env, ids.entryB); got != "registered" {
t.Fatalf("B 应恢复为 registered,得到 %s", got)
}
if got := activeLedgerPoints(t, env, ids.entryB); !equalFloats(got, []float64{8}) {
t.Fatalf("B 恢复后应重算回 8 分,得到 %v", got)
}
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{6}) {
t.Fatalf("C 恢复后应重算回 6 分,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 8 {
t.Fatalf("B 单位总分应恢复为 8,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 6 {
t.Fatalf("C 单位总分应恢复为 6,得到 %v", got)
}
// 恢复后名次写回:B 回到第 2 名(A 第一、C 第三)。
var restoredRank sql.NullInt64
if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, ids.entryB).Scan(&restoredRank); err != nil {
t.Fatalf("读恢复后的名次: %v", err)
}
if !restoredRank.Valid || restoredRank.Int64 != 2 {
t.Fatalf("B 恢复后应重算回第 2 名,得到 %+v", restoredRank)
}
assertNoForeignKeyViolation(t, env)
}
// 已是 dq 的行不能被退赛覆盖;不存在的 id 逐条报错;整批不失败。
func TestEntryBatchScratchSkipsDQAndReportsMissing(t *testing.T) {
env, ids, client := recorderWithSeed(t)
env.exec(t, `UPDATE entries SET status = 'dq' WHERE id = ?`, ids.entryB)
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryA, ids.entryB, int64(999999)},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("混选批量退赛应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if len(result.Items) != 3 {
t.Fatalf("3 个 id 应有 3 条逐条结果,得到 %+v", result.Items)
}
if !result.Items[0].OK || result.Items[0].Skipped {
t.Fatalf("A 应正常退赛,得到 %+v", result.Items[0])
}
if !result.Items[1].OK || !result.Items[1].Skipped || !strings.Contains(result.Items[1].Note, "dq") {
t.Fatalf("B 应被跳过并说明是 dq,得到 %+v", result.Items[1])
}
if result.Items[2].OK || result.Items[2].Error != "报名项不存在" {
t.Fatalf("不存在的 id 应逐条报错,得到 %+v", result.Items[2])
}
if result.Summary.OK != 2 || result.Summary.Failed != 1 || result.Summary.Skipped != 1 || result.Summary.Changed != 1 {
t.Fatalf("汇总应是 2 成功(含 1 跳过)/ 1 失败,得到 %+v", result.Summary)
}
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
t.Fatalf("A 应已退赛,得到 %s", got)
}
if got := entryStatus(t, env, ids.entryB); got != "dq" {
t.Fatalf("B 的 dq 判定不应被覆盖,得到 %s", got)
}
assertNoForeignKeyViolation(t, env)
}
// 移出报名:报名、成绩、流水全部按 entry_id 清干净(不留 entry_id 置空的空壳流水),
// 留下的人按新名次重算。
func TestEntryBatchRemovePurgesLedgerByEntryID(t *testing.T) {
env, ids, client := recorderWithSeed(t)
teamC, entryC := addTeamAthleteEntry(t, env, ids, "C", "第三分公司", "王超")
publishResults(t, env, client, ids.event, "remove",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
map[string]any{"entryId": entryC, "resultValue": 12.10, "resultText": "12.10", "unit": "s", "status": "ok"},
)
// 破纪录登记也要跟着消失,否则会留下一条挂不上报名项的记录。
env.exec(t, `INSERT INTO record_breaks (event_id, entry_id, level, result_value, bonus_points, state)
VALUES (?, ?, 'meet', 11.92, 5, 'pending')`, ids.event, ids.entryB)
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "remove", "ids": []int64{ids.entryB},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if result.Summary.RemovedEntries != 1 || result.Summary.RemovedResults != 1 ||
result.Summary.RemovedLedger != 1 || result.Summary.RemovedBreaks != 1 {
t.Fatalf("汇总应带回删除规模,得到 %+v", result.Summary)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryB); n != 0 {
t.Fatalf("报名项应被物理删除,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE entry_id = ?`, ids.entryB); n != 0 {
t.Fatalf("成绩应被删除,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE entry_id = ?`, ids.entryB); n != 0 {
t.Fatalf("流水应按 entry_id 清干净,得到 %d 条", n)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM record_breaks WHERE entry_id = ?`, ids.entryB); n != 0 {
t.Fatalf("破纪录登记应被删除,得到 %d 条", n)
}
// 关键证据:删完之后不能留下 entry_id 被 SET NULL 置空的成绩流水。
if n := countRows(t, env,
`SELECT COUNT(*) FROM score_ledger WHERE entry_id IS NULL AND result_id IS NULL AND source_type = 'result'`); n != 0 {
t.Fatalf("不应留下 entry_id 被置空的空壳流水,得到 %d 条", n)
}
// 留下的人被重算:C 从第三 6 分升到第二 8 分。
if got := activeLedgerPoints(t, env, entryC); !equalFloats(got, []float64{8}) {
t.Fatalf("C 应随新名次重算成 8 分,得到 %v", got)
}
if got := teamPoints(t, env, client, teamC); got != 8 {
t.Fatalf("C 单位总分应重算成 8,得到 %v", got)
}
if got := teamPoints(t, env, client, ids.teamB); got != 0 {
t.Fatalf("被移出的单位总分应归零,得到 %v", got)
}
assertNoForeignKeyViolation(t, env)
}
// 跨小项混选允许,但要按 (小项, 组别) 分组各算各的。
func TestEntryBatchScratchAcrossEvents(t *testing.T) {
env, ids, client := recorderWithSeed(t)
event2 := 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)
category2 := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order)
VALUES (?, '男子', 1, 0)`, event2)
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)`, event2, profileID)
entry2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status)
VALUES (?, ?, ?, ?, 'registered')`, event2, category2, ids.teamA, ids.athleteA)
publishResults(t, env, client, ids.event, "cross1",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"})
publishResults(t, env, client, event2, "cross2",
map[string]any{"entryId": entry2, "resultValue": 23.10, "resultText": "23.10", "unit": "s", "status": "ok"})
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "scratch", "ids": []int64{ids.entryA, entry2},
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("跨小项批量退赛应 200,得到 %d %+v", status, env2.Error)
}
result := decodeData[batchResult](t, env2)
if len(result.Summary.RecomputedEvent) != 2 ||
result.Summary.RecomputedEvent[0] != ids.event || result.Summary.RecomputedEvent[1] != event2 {
t.Fatalf("两个小项都应重算,得到 %+v", result.Summary.RecomputedEvent)
}
if got := entryStatus(t, env, ids.entryA); got != "scratched" {
t.Fatalf("小项一的报名应已退赛,得到 %s", got)
}
if got := entryStatus(t, env, entry2); got != "scratched" {
t.Fatalf("小项二的报名应已退赛,得到 %s", got)
}
}
// 边界与权限:空 ids、重复 ids、超上限、非法 action、guest 写。
func TestEntryBatchValidation(t *testing.T) {
env, ids, client := recorderWithSeed(t)
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "scratch", "ids": []int64{}}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("空 ids 应 400,得到 %d %+v", status, env2.Error)
}
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "nuke", "ids": []int64{ids.entryA}}, true)
if status != http.StatusBadRequest {
t.Fatalf("非法 action 应 400,得到 %d %+v", status, env2.Error)
}
// 重复 id 去重后只回报一条。
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "scratch", "ids": []int64{ids.entryA, ids.entryA}}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("带重复 id 的请求应 200,得到 %d %+v", status, env2.Error)
}
dup := decodeData[batchResult](t, env2)
if len(dup.Items) != 1 || dup.Summary.Changed != 1 {
t.Fatalf("重复 id 应去重成一条,得到 %+v", dup)
}
// 超过上限要在动手之前拒绝。
big := make([]int64, 501)
for i := range big {
big[i] = int64(i + 1)
}
status, env2 = env.do(client, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "remove", "ids": big}, true)
if status != http.StatusBadRequest || env2.Error == nil || !strings.Contains(env2.Error.Message, "分批") {
t.Fatalf("超过上限应 400 并要求分批,得到 %d %+v", status, env2.Error)
}
// guest(未登录)不能写。
guest, _ := env.client()
status, env2 = env.do(guest, http.MethodPost, "/api/entries/batch",
map[string]any{"action": "remove", "ids": []int64{ids.entryA}}, false)
if status != http.StatusUnauthorized {
t.Fatalf("未登录批量操作应 401,得到 %d %+v", status, env2.Error)
}
if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE id = ?`, ids.entryA); n != 1 {
t.Fatalf("guest 不应删掉任何报名项,得到 %d 条", n)
}
}
// waitBatchAudit 取批量操作的审计 after_json:批量动作没有单个实体 id
// (entity_id 落库为 NULL),所以按 action 找,和 waitAudit 的单实体口径区分开。
func waitBatchAudit(t *testing.T, env *testEnv, action string) string {
t.Helper()
for i := 0; i < 100; i++ {
var after sql.NullString
err := env.ev.QueryRow(`SELECT after_json FROM audit_logs WHERE action = ? AND entity_id IS NULL`,
action).Scan(&after)
switch {
case err == nil:
return after.String
case errors.Is(err, sql.ErrNoRows):
time.Sleep(10 * time.Millisecond)
default:
t.Fatalf("查询审计: %v", err)
}
}
t.Fatalf("等不到 %s 的审计记录", action)
return ""
}
// 审计要带 ids 与 summary,三个动作各一条。
func TestEntryBatchAudit(t *testing.T) {
env, ids, client := recorderWithSeed(t)
for _, action := range []string{"scratch", "restore", "remove"} {
status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": action, "ids": []int64{ids.entryA}, "reason": "实机验证",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("%s 应 200,得到 %d %+v", action, status, env2.Error)
}
after := waitBatchAudit(t, env, "entry."+action)
for _, want := range []string{fmt.Sprintf(`"ids":[%d]`, ids.entryA), `"summary"`, `"实机验证"`} {
if !strings.Contains(after, want) {
t.Fatalf("%s 审计应含 %s,得到 %s", action, want, after)
}
}
}
}
// 保障:remove 之后 results 表里的 rank 不能留下指向已删报名项的悬挂行。
func TestEntryBatchRemoveKeepsOtherResultsIntact(t *testing.T) {
env, ids, client := recorderWithSeed(t)
publishResults(t, env, client, ids.event, "keep",
map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"},
map[string]any{"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"},
)
if status, env2 := env.do(client, http.MethodPost, "/api/entries/batch", map[string]any{
"action": "remove", "ids": []int64{ids.entryA},
}, true); status != http.StatusOK || !env2.OK {
t.Fatalf("移出报名应 200,得到 %d %+v", status, env2.Error)
}
var (
rank sql.NullInt64
state string
)
if err := env.ev.QueryRow(`SELECT rank, state FROM results WHERE entry_id = ?`, ids.entryB).
Scan(&rank, &state); err != nil {
t.Fatalf("读 B 的成绩: %v", err)
}
if !rank.Valid || rank.Int64 != 1 || state != "published" {
t.Fatalf("B 的名次应重算为 1 且保持发布态,得到 rank=%+v state=%s", rank, state)
}
}