package httpapi_test import ( "fmt" "net/http" "strings" "testing" ) // 本文件钉住「联队 + 份额分成」从导入到榜单的完整链路(devdocs/19): // - 报名表 team_code 写 `A+B` → 一条报名挂两个单位(默认均分),主单位是加号左边那个; // - 团体第 1 名 15 分 → 两个单位各 7.5 分;金牌两个单位**各记 1 枚**(不是各 0.5 枚); // - 权重可配(2:1 → 10 / 5),改完当场重算推榜; // - 非法写法(单位不存在 / 缺一段 / 重复 / 太多)必须拦下并给出照得着改的报错。 // seedMatchEvent 造一个「球类」小项:match 类型 + 按单位录名次的团体通道 // (规则包给 match 的模板就是 scope=team + basis=manual),返回小项与组别 id。 func seedMatchEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64) { t.Helper() eventID = env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) VALUES (?, 'BALL', '篮球', 'match', 'manual', 'count', 'none', 1, 1, 'ongoing')`, ids.sport) categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, eventID) env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) VALUES (?, 'team', 'manual', ?, 0)`, eventID, profileIDByCode(t, env, "team_top6")) return eventID, categoryID } // jointImportRow 拼一行团体报名(无姓名 = 团体行),列序与 importHeader 一致。 func jointImportRow(eventCode, category, teamCode string) string { return strings.Join([]string{eventCode, category, teamCode, "", "", "", "", "", "", "registered", "张伟/李强"}, ",") } // importCSV 上传一份单表 CSV(表头 + 若干行)。 func importCSV(t *testing.T, env *testEnv, client *http.Client, rows ...string) (int, envelope) { t.Helper() content := importHeader + "\n" + strings.Join(rows, "\n") + "\n" return env.postImport(t, client, "/api/entries/import", content, "") } // sharesDump 导出某小项「报名 + 份额」的原始落库事实(列序见下面的查询): // share_team 为 "" 表示这条报名一条份额都没有(不该出现)。 func sharesDump(t *testing.T, env *testEnv, eventID int64) string { t.Helper() return tableDump(t, env, fmt.Sprintf( `SELECT e.id AS entry_id, COALESCE(e.athlete_id, 0) AS athlete_id, e.team_id, COALESCE(s.team_id, 0) AS share_team, COALESCE(s.points_share, 0) AS points_share, COALESCE(s.medal_share, 0) AS medal_share, COALESCE(s.sort_order, 0) AS sort_order FROM entries e LEFT JOIN entry_team_shares s ON s.entry_id = e.id WHERE e.event_id = %d ORDER BY e.id, s.sort_order, s.team_id`, eventID)) } // shareRow 按 tableDump 的格式拼一行份额(列名=值,行尾一个空格)。 func shareRow(entryID, athleteID, teamID, shareTeam int64, weight float64, medal, order int) string { return fmt.Sprintf("entry_id=%d athlete_id=%d team_id=%d share_team=%d points_share=%v medal_share=%d sort_order=%d \n", entryID, athleteID, teamID, shareTeam, weight, medal, order) } // shareAbsentRow 是「这条报名一条份额行都没有」的 dump 行:种子 / 手工造的报名直接写 entries, // 绕过了导入,库里就没有份额行——读侧与引擎都按主单位独占 100% 兜底(老数据同理)。 func shareAbsentRow(entryID, athleteID, teamID int64) string { return fmt.Sprintf("entry_id=%d athlete_id=%d team_id=%d share_team=0 points_share=0 medal_share=0 sort_order=0 \n", entryID, athleteID, teamID) } // firstEntryID 取某小项的第一条报名 id。 func firstEntryID(t *testing.T, env *testEnv, eventID int64) int64 { t.Helper() var id int64 if err := env.ev.QueryRow(`SELECT id FROM entries WHERE event_id = ? ORDER BY id LIMIT 1`, eventID).Scan(&id); err != nil { t.Fatalf("读报名项: %v", err) } return id } // standingsRow 取积分榜上某个单位那一行(REST /api/standings 的原始读数)。 func standingsRow(t *testing.T, env *testEnv, client *http.Client, teamID int64) map[string]any { t.Helper() status, resp := env.do(client, http.MethodGet, "/api/standings", nil, false) if status != http.StatusOK || !resp.OK { t.Fatalf("读积分榜应 200,得到 %d %+v", status, resp.Error) } for _, row := range decodeData[[]map[string]any](t, resp) { if row["teamId"] == float64(teamID) { return row } } t.Fatalf("积分榜上没有单位 %d", teamID) return nil } // assertStanding 断言某单位的总分与金银铜。 func assertStanding(t *testing.T, env *testEnv, client *http.Client, teamID int64, points float64, gold, silver, bronze int) { t.Helper() row := standingsRow(t, env, client, teamID) if got := row["totalPoints"].(float64); got != points { t.Errorf("单位 %d 总分 = %v,期望 %v(整行:%+v)", teamID, got, points, row) } for name, want := range map[string]int{"gold": gold, "silver": silver, "bronze": bronze} { if got := row[name].(float64); got != float64(want) { t.Errorf("单位 %d 的 %s = %v,期望 %d(整行:%+v)", teamID, name, got, want, row) } } } // 现场场景:两个单位因人数不足合并参赛,团体第 1 名 15 分 → A、B 各 7.5 分、各 1 枚金牌。 func TestImportJointTeamSplitsPointsAndMedals(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, categoryID := seedMatchEvent(t, env, ids) status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B")) if status != http.StatusOK || !resp.OK { t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error) } if result := decodeData[map[string]any](t, resp); result["created"].(float64) != 1 { t.Fatalf("应新建 1 条报名,得到 %+v", result) } // 主单位是加号左边那个(A),份额表里两条均分、奖牌各记整枚。 entryID := firstEntryID(t, env, eventID) want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) + shareRow(entryID, 0, ids.teamA, ids.teamB, 1, 1, 1) if got := sharesDump(t, env, eventID); got != want { t.Fatalf("联队报名落库不对:\n得到 %s期望 %s", got, want) } // 团体名次录在主单位上(现场就是按单位录名次):联队第 1 名。 if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{ {"teamId": ids.teamA, "rank": 1}, }); status != http.StatusOK { t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error) } // 积分各 7.5(允许出现小数),金牌各 1 枚。 assertStanding(t, env, client, ids.teamA, 7.5, 1, 0, 0) assertStanding(t, env, client, ids.teamB, 7.5, 1, 0, 0) ledger := tableDump(t, env, `SELECT team_id, points FROM score_ledger WHERE voided = 0 ORDER BY team_id`) wantLedger := fmt.Sprintf("team_id=%d points=7.5 \nteam_id=%d points=7.5 \n", ids.teamA, ids.teamB) if ledger != wantLedger { t.Fatalf("积分流水应按份额拆成两条:\n得到 %s期望 %s", ledger, wantLedger) } } // 权重可配:改成 2:1 → 10 / 5;奖牌仍是各 1 枚(两个口径互不影响)。 func TestUpdateEntryTeamSharesWeights(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, categoryID := seedMatchEvent(t, env, ids) if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B")); status != http.StatusOK { t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{ {"teamId": ids.teamA, "rank": 1}, }); status != http.StatusOK { t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error) } assertStanding(t, env, client, ids.teamA, 7.5, 1, 0, 0) path := fmt.Sprintf("/api/entries/%d/team-shares", entryID) status, resp := env.do(client, http.MethodPut, path, map[string]any{ "teamShares": []map[string]any{ {"teamId": ids.teamA, "weight": 2}, {"teamId": ids.teamB, "weight": 1}, }, }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("改份额应 200,得到 %d %+v", status, resp.Error) } updated := decodeData[map[string]any](t, resp) shares, _ := updated["teamShares"].([]any) if len(shares) != 2 { t.Fatalf("回显应带两条份额,得到 %+v", updated["teamShares"]) } first, _ := shares[0].(map[string]any) if first["teamId"] != float64(ids.teamA) || first["weight"] != float64(2) { t.Fatalf("回显的第一条应是主单位 A 权重 2,得到 %+v", first) } if first["teamCode"] != "A" || first["teamName"] != "第一分公司" { t.Fatalf("回显应带上单位编码与名称(前端拼「A+B联队」用),得到 %+v", first) } // 改完当场重算:2:1 拆成 10 / 5,奖牌不受权重影响,仍是各 1 枚。 assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0) assertStanding(t, env, client, ids.teamB, 5, 1, 0, 0) // 奖牌开关按单位存(medal=false 落成 medal_share=0 再读回来),积分照旧按权重拆。 status, resp = env.do(client, http.MethodPut, path, map[string]any{ "teamShares": []map[string]any{ {"teamId": ids.teamA, "weight": 2}, {"teamId": ids.teamB, "weight": 1, "medal": false}, }, }, true) if status != http.StatusOK || !resp.OK { t.Fatalf("关掉成员单位的奖牌应 200,得到 %d %+v", status, resp.Error) } shares, _ = decodeData[map[string]any](t, resp)["teamShares"].([]any) second, _ := shares[1].(map[string]any) if second["medal"] != false { t.Fatalf("回显应带上「这个单位不记奖牌」(medal:false),得到 %+v", second) } assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0) assertStanding(t, env, client, ids.teamB, 5, 0, 0, 0) } // 非法份额的报错必须说清怎么改(人话),且一律不落库。 func TestUpdateEntryTeamSharesRejectsBadPayloads(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedMatchEvent(t, env, ids) if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A")); status != http.StatusOK { t.Fatalf("导入单人报名应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) path := fmt.Sprintf("/api/entries/%d/team-shares", entryID) baseline := tableDump(t, env, `SELECT team_id, points_share FROM entry_team_shares ORDER BY team_id`) cases := []struct { name string body map[string]any want string }{ {"空份额", map[string]any{"teamShares": []map[string]any{}}, "teamShares 不能为空"}, {"缺主单位", map[string]any{"teamShares": []map[string]any{{"teamId": ids.teamB}}}, "必须包含这条报名的主单位"}, {"单位重复", map[string]any{"teamShares": []map[string]any{ {"teamId": ids.teamA}, {"teamId": ids.teamA}}}, "出现了两次"}, {"权重为负", map[string]any{"teamShares": []map[string]any{ {"teamId": ids.teamA, "weight": -1}}}, "不能为负数"}, {"单位不存在", map[string]any{"teamShares": []map[string]any{ {"teamId": ids.teamA}, {"teamId": 999999}}}, "不存在或已删除"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { status, resp := env.do(client, http.MethodPut, path, tc.body, true) if status != http.StatusBadRequest { t.Fatalf("应 400,得到 %d %+v", status, resp) } if resp.Error == nil || !strings.Contains(resp.Error.Message, tc.want) { t.Fatalf("报错应含 %q,得到 %+v", tc.want, resp.Error) } // 拦下就不能落库:份额表必须一个字节都没动。 if got := tableDump(t, env, `SELECT team_id, points_share FROM entry_team_shares ORDER BY team_id`); got != baseline { t.Fatalf("被拒的请求改动了份额表:\n之前 %s之后 %s", baseline, got) } }) } } // 报名表里非法写法的联队:拦下这一行并给出照得着改的报错,其余行照常导入。 func TestImportJointTeamRejectsBadForms(t *testing.T) { cases := []struct { name string teamCode string want string }{ {"成员单位不存在", "A+ZZ", "单位 ZZ 不存在(来自联队编码 A+ZZ)"}, {"加号后面缺一段", "A+", "里有一段是空的"}, {"加号前面缺一段", "+B", "里有一段是空的"}, {"同一个单位写两遍", "A+A", "同一个单位出现了两次"}, {"成员太多", "A+B+C+D+E+F", "一个联队最多 5 个单位"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedMatchEvent(t, env, ids) // 前面一行是正常的单单位报名:它必须照常导入(坏行不能拖垮整张表)。 status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A"), jointImportRow("BALL", "男子", tc.teamCode), ) if status != http.StatusOK || !resp.OK { t.Fatalf("导入端点应 200(逐行报错),得到 %d %+v", status, resp.Error) } result := decodeData[map[string]any](t, resp) if result["created"].(float64) != 1 || result["skipped"].(float64) != 1 { t.Fatalf("应 1 条成功 1 条跳过,得到 %+v", result) } errs, _ := result["errors"].([]any) if len(errs) != 1 || !strings.Contains(fmt.Sprint(errs[0]), tc.want) { t.Fatalf("第 2 行的报错应含 %q,得到 %+v", tc.want, errs) } if n := countRows(t, env, `SELECT COUNT(*) FROM entries WHERE event_id = ?`, eventID); n != 1 { t.Fatalf("坏行不该落库,报名数 = %d(明细:\n%s)", n, sharesDump(t, env, eventID)) } // 成功那行必须是「主单位独占」一条:坏行的解析结果不能污染它。 entryID := firstEntryID(t, env, eventID) want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) if got := sharesDump(t, env, eventID); got != want { t.Fatalf("正常行应只挂主单位一条份额:\n得到 %s期望 %s", got, want) } }) } } // 全角加号与两侧空格照样认(现场用中文输入法填表,打出来的常是全角)。 func TestImportJointTeamAcceptsFullWidthPlus(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedMatchEvent(t, env, ids) if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A + B")); status != http.StatusOK { t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) + shareRow(entryID, 0, ids.teamA, ids.teamB, 1, 1, 1) if got := sharesDump(t, env, eventID); got != want { t.Fatalf("全角加号应解析成同一个联队:\n得到 %s期望 %s", got, want) } } // 联队改回单单位(或换成员单位)时份额必须跟着变:只增不改会把旧配置一直用下去。 func TestImportJointTeamResetsSharesOnReimport(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, _ := seedMatchEvent(t, env, ids) if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A+B")); status != http.StatusOK { t.Fatalf("导入联队报名应 200,得到 %d %+v", status, resp.Error) } status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A")) if status != http.StatusOK || !resp.OK { t.Fatalf("重传单单位应 200,得到 %d %+v", status, resp.Error) } if result := decodeData[map[string]any](t, resp); result["updated"].(float64) != 1 { t.Fatalf("应更新同一行,得到 %+v", result) } entryID := firstEntryID(t, env, eventID) want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) if got := sharesDump(t, env, eventID); got != want { t.Fatalf("改回单单位后份额应只剩主单位一条:\n得到 %s期望 %s", got, want) } } // 单个单位的报名照旧:库里写一条「主单位独占 100%」,读接口也下发一条,前端不必分支。 func TestSingleUnitEntryKeepsWholeShare(t *testing.T) { env, ids, client := recorderWithSeed(t) eventID, categoryID := seedMatchEvent(t, env, ids) if status, resp := importCSV(t, env, client, jointImportRow("BALL", "男子", "A")); status != http.StatusOK { t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error) } entryID := firstEntryID(t, env, eventID) want := shareRow(entryID, 0, ids.teamA, ids.teamA, 1, 1, 0) if got := sharesDump(t, env, eventID); got != want { t.Fatalf("单单位报名应写一条独占份额:\n得到 %s期望 %s", got, want) } board := getEntryBoard(t, env, client, eventID) if len(board.Entries) != 1 { t.Fatalf("应有一条报名,得到 %+v", board.Entries) } shares, _ := board.Entries[0]["teamShares"].([]any) if len(shares) != 1 { t.Fatalf("读接口应下发一条份额(普通报名也一样),得到 %+v", board.Entries[0]["teamShares"]) } share, _ := shares[0].(map[string]any) if share["teamId"] != float64(ids.teamA) || share["weight"] != float64(1) || share["teamCode"] != "A" { t.Fatalf("份额读数应为 {teamId:A, weight:1, teamCode:A},得到 %+v", share) } // 独占口径下名次与积分照旧:第 1 名 15 分全给 A,B 一分没有。 if status, resp := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{ {"teamId": ids.teamA, "rank": 1}, }); status != http.StatusOK { t.Fatalf("录团体名次应 200,得到 %d %+v", status, resp.Error) } assertStanding(t, env, client, ids.teamA, 15, 1, 0, 0) assertStanding(t, env, client, ids.teamB, 0, 0, 0, 0) } // 人员匹配可能把报名改挂到库里那条档案的单位(keep-existing 的既有口径「报名跟随库里已有的单位」): // 份额必须跟着**最终主单位**走。跟不上的话就会出现「报名挂在 A 名下、分却记给了表格里写的 B」—— // 现场看不出来,但榜单直接是错的。 func TestImportSharesFollowMatchedUnit(t *testing.T) { env, ids, client := recorderWithSeed(t) env.exec(t, `UPDATE athletes SET id_card = '110101199001011234', bib = 'A100' WHERE id = ?`, ids.athleteA) // 表里写 B,库里这个人的单位是 A,用户选 keep-existing → 报名仍挂在 A。 body := importHeader + "\nM100,男子,B,张伟,110101199001011234,男,,A100,,registered,\n" decisions := `[{"idCard":"110101199001011234","action":"keep-existing"}]` status, resp := env.postImport(t, client, "/api/entries/import/resolve", body, decisions) if status != http.StatusOK || !resp.OK { t.Fatalf("决策导入应 200,得到 %d %+v", status, resp.Error) } want := shareRow(ids.entryA, ids.athleteA, ids.teamA, ids.teamA, 1, 1, 0) + shareAbsentRow(ids.entryB, ids.athB, ids.teamB) if got := sharesDump(t, env, ids.event); got != want { t.Fatalf("份额应跟随最终主单位 A:\n得到 %s期望 %s", got, want) } // 榜单上分也只能记给 A:报名挂在 A 名下,B 一分没有。 publishResults(t, env, client, ids.event, "shares-follow-matched-unit", map[string]any{"entryId": ids.entryA, "resultValue": 11.2, "resultText": "11.2", "unit": "秒", "status": "ok"}) assertStanding(t, env, client, ids.teamA, 10, 1, 0, 0) assertStanding(t, env, client, ids.teamB, 0, 0, 0, 0) }