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, ¬e); 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 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 ) if err := rows.Scan(&l.ID, &l.TeamID, &athlete, &event, &entry, &result, &l.SourceType, &l.SourceID, &l.Points, &l.Voided, &created); 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 out = append(out, l) } return out, rows.Err() }