Files
Scoreboard/server/internal/httpapi/repo_results.go
T
AskaEth 6bd7bd1627 feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名
口径(用户确认):
- 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数)
- 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分
- 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、
  没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存

服务端:
- 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team;
  events 加 avg_mode(默认 valid)
- 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team →
  默认 athlete(result, 不计入单位) + team(aggregate 队内平均)
- 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名
  (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与)

前端:
- 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」
- 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存
- 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致)
- 大屏/管理端团体成绩只显示数值,不出现「平均」字样

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
2026-09-10 12:02:00 +08:00

301 lines
8.9 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
import (
"context"
"database/sql"
"encoding/json"
"errors"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// ---------- 成员姓名 JSON 数组 ----------
func parseNames(ns sql.NullString) []string {
if !ns.Valid || ns.String == "" {
return nil
}
var out []string
if err := json.Unmarshal([]byte(ns.String), &out); err != nil {
return nil
}
return out
}
func marshalNames(names []string) any {
if len(names) == 0 {
return nil
}
raw, err := json.Marshal(names)
if err != nil {
return nil
}
return string(raw)
}
// ---------- results ----------
const resultColumns = `id, event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, verified_by, verified_at, note`
func scanResult(row interface{ Scan(...any) error }) (resultDTO, error) {
var (
r resultDTO
rank sql.NullInt64
text, unit sql.NullString
value, override sql.NullFloat64
recordedBy, verifiedBy sql.NullInt64
recordedAt, verifiedAt, note sql.NullString
)
if err := row.Scan(&r.ID, &r.EventID, &r.EntryID, &rank, &text, &value, &unit, &r.IsTie,
&override, &r.Status, &r.State, &recordedBy, &recordedAt, &verifiedBy, &verifiedAt, &note); err != nil {
return r, err
}
r.Rank = intPtr(rank)
r.ResultText = text.String
r.ResultValue = f64Ptr(value)
r.Unit = unit.String
r.PointsOverride = f64Ptr(override)
r.RecordedBy = i64Ptr(recordedBy)
r.RecordedAt = strPtr(recordedAt)
r.VerifiedBy = i64Ptr(verifiedBy)
r.VerifiedAt = strPtr(verifiedAt)
r.Note = strPtr(note)
return r, nil
}
func (s *server) resultsByEvent(ctx context.Context, eventID int64) ([]resultDTO, error) {
return queryResults(ctx, s.ev, `SELECT `+resultColumns+` FROM results WHERE event_id = ? ORDER BY id`, eventID)
}
func queryResults(ctx context.Context, q queryer, query string, args ...any) ([]resultDTO, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]resultDTO, 0)
for rows.Next() {
r, err := scanResult(rows)
if err != nil {
return nil, internal(err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
return nil, internal(err)
}
if err := attachResultPoints(ctx, q, out); err != nil {
return nil, err
}
return out, nil
}
// attachResultPoints 给已发布成绩补上得分(该成绩产生的 result 流水合计)。
// 草稿没有流水,保持 nil;录入页据此预览发布后的分值变化。
func attachResultPoints(ctx context.Context, q queryer, items []resultDTO) error {
ids := make([]any, 0, len(items))
index := make(map[int64]int, len(items))
for i := range items {
if items[i].State != "published" {
continue
}
ids = append(ids, items[i].ID)
index[items[i].ID] = i
}
if len(ids) == 0 {
return nil
}
rows, err := q.QueryContext(ctx,
`SELECT result_id, SUM(points) FROM score_ledger
WHERE voided = 0 AND source_type = 'result' AND result_id IN (`+placeholders(len(ids))+`)
GROUP BY result_id`, ids...)
if err != nil {
return internal(err)
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var (
resultID int64
points float64
)
if err := rows.Scan(&resultID, &points); err != nil {
return internal(err)
}
if i, ok := index[resultID]; ok {
v := points
items[i].Points = &v
}
}
if err := rows.Err(); err != nil {
return internal(err)
}
return nil
}
func (s *server) getResult(ctx context.Context, id int64) (resultDTO, error) {
r, err := scanResult(s.ev.QueryRowContext(ctx, `SELECT `+resultColumns+` FROM results WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return resultDTO{}, notFound("成绩不存在")
}
if err != nil {
return resultDTO{}, internal(err)
}
items := []resultDTO{r}
if err := attachResultPoints(ctx, s.ev, items); err != nil {
return resultDTO{}, err
}
return items[0], nil
}
func (s *server) listResultsPage(ctx context.Context, eventID int64, state string, page, size int) (pageResult[resultDTO], error) {
where := []string{"1 = 1"}
args := []any{}
if eventID > 0 {
where = append(where, "event_id = ?")
args = append(args, eventID)
}
if state != "" {
where = append(where, "state = ?")
args = append(args, state)
}
clause := " WHERE " + joinAnd(where)
var total int
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM results`+clause, args...).Scan(&total); err != nil {
return pageResult[resultDTO]{}, internal(err)
}
query := `SELECT ` + resultColumns + ` FROM results` + clause + ` ORDER BY id LIMIT ? OFFSET ?`
args = append(args, size, (page-1)*size)
items, err := queryResults(ctx, s.ev, query, args...)
if err != nil {
return pageResult[resultDTO]{}, err
}
return pageResult[resultDTO]{Items: items, Total: total, Page: page, Size: size}, nil
}
// upsertDraft 批量保存草稿;已发布的成绩直接拒绝(STATE_INVALID),
// 必须先撤销发布再改,避免 rank 被就地清空而流水不动。
func (s *server) upsertDraft(ctx context.Context, eventID int64, userID int64, items []draftResultInput) ([]resultDTO, error) {
tx, err := s.ev.BeginTx(ctx, nil)
if err != nil {
return nil, internal(err)
}
defer func() { _ = tx.Rollback() }()
for i, it := range items {
if it.EntryID == 0 {
return nil, badRequest("results[].entryId 必填", i)
}
var entryEvent int64
if err := tx.QueryRowContext(ctx, `SELECT event_id FROM entries WHERE id = ?`, it.EntryID).Scan(&entryEvent); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, badRequest("entryId 不存在", it.EntryID)
}
return nil, internal(err)
}
if entryEvent != eventID {
return nil, badRequest("entryId 不属于该小项", it.EntryID)
}
// 已发布成绩不能被「存草稿」就地改写:ON CONFLICT 不改 state,
// 若放行会把 rank 清空但流水不动,大屏奖牌与积分立刻对不上。
var state string
err := tx.QueryRowContext(ctx, `SELECT state FROM results WHERE entry_id = ?`, it.EntryID).Scan(&state)
switch {
case errors.Is(err, sql.ErrNoRows):
// 尚无成绩,正常新建草稿。
case err != nil:
return nil, internal(err)
case state == string(domain.ResultStatePublished):
return nil, stateInvalid("该成绩已发布,请先撤销发布再修改")
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO results (event_id, entry_id, rank, result_text, result_value, unit, is_tie,
points_override, status, state, recorded_by, recorded_at, note, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, datetime('now'), ?, datetime('now'))
ON CONFLICT(entry_id) DO UPDATE SET
rank = excluded.rank,
result_text = excluded.result_text,
result_value = excluded.result_value,
unit = COALESCE(excluded.unit, results.unit),
is_tie = excluded.is_tie,
points_override = excluded.points_override,
status = excluded.status,
note = excluded.note,
recorded_by = excluded.recorded_by,
recorded_at = excluded.recorded_at,
updated_at = datetime('now')`,
eventID, it.EntryID, nullInt(it.Rank), nullStr(it.ResultText), nullF64(it.ResultValue),
nullStr(it.Unit), it.IsTie, nullF64(it.PointsOverride), orDefault(it.Status, "ok"),
nullID(userID), nullStr(it.Note)); err != nil {
return nil, internal(err)
}
}
if err := tx.Commit(); err != nil {
return nil, internal(err)
}
return s.resultsByEvent(ctx, eventID)
}
func nullID(id int64) any {
if id == 0 {
return nil
}
return id
}
func joinAnd(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += " AND "
}
out += p
}
return out
}
// ---------- ledger ----------
func (s *server) activeLedger(ctx context.Context, q queryer) ([]domain.LedgerEntry, error) {
return queryLedger(ctx, q, `SELECT id, team_id, athlete_id, event_id, entry_id, result_id,
source_type, source_id, points, voided, created_at, count_to_team FROM score_ledger WHERE voided = 0`)
}
func queryLedger(ctx context.Context, q queryer, query string, args ...any) ([]domain.LedgerEntry, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, internal(err)
}
defer func() { _ = rows.Close() }()
out := make([]domain.LedgerEntry, 0)
for rows.Next() {
var (
l domain.LedgerEntry
athlete sql.NullInt64
event sql.NullInt64
entry sql.NullInt64
result sql.NullInt64
created sql.NullString
countToTeam int
)
if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result,
&l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created, &countToTeam); err != nil {
return nil, internal(err)
}
l.AthleteID = athlete.Int64
l.EventID = event.Int64
l.EntryID = entry.Int64
l.ResultID = result.Int64
l.CreatedAt = created.String
l.CountToTeam = countToTeam != 0
out = append(out, l)
}
return out, rows.Err()
}