用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:
**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
`POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
`deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。
**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。
**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
必须自己补校验(库层没有复合外键)。
**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。
测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
1151 lines
40 KiB
Go
1151 lines
40 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/dto"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
|
||
)
|
||
|
||
// ---------- settings ----------
|
||
|
||
func loadSettingsMap(ctx context.Context, q queryer) (map[string]string, error) {
|
||
rows, err := q.QueryContext(ctx, `SELECT key, value FROM settings`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make(map[string]string)
|
||
for rows.Next() {
|
||
var k, v string
|
||
if err := rows.Scan(&k, &v); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) getSettings(ctx context.Context) (dto.EventSettings, error) {
|
||
return s.settingsSnapshotQ(ctx, s.ev)
|
||
}
|
||
|
||
// settingsSnapshotQ 在给定连接/事务内构造与 GET /api/settings 同构的配置,
|
||
// 供写事务内的 change_log 载荷复用,保证大屏收到的 settings 与 REST 口径一致。
|
||
func (s *server) settingsSnapshotQ(ctx context.Context, q queryer) (dto.EventSettings, error) {
|
||
values, err := loadSettingsMap(ctx, q)
|
||
if err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
|
||
out := dto.ParseEventSettings(values)
|
||
|
||
if code := values["scoring.default_profile"]; code != "" {
|
||
var id int64
|
||
err := q.QueryRowContext(ctx, `SELECT id FROM scoring_profiles WHERE code = ?`, code).Scan(&id)
|
||
switch {
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
// 配置引用的模板已被删除:返回 null,由管理端提示重新选择。
|
||
case err != nil:
|
||
return dto.EventSettings{}, internal(err)
|
||
default:
|
||
out.DefaultProfileID = &id
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (s *server) updateSettings(ctx context.Context, patch settingsPatch) (dto.EventSettings, error) {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return dto.EventSettings{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
set := func(key, value string) error {
|
||
_, err := tx.ExecContext(ctx,
|
||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||
key, value)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
return nil
|
||
}
|
||
setPtr := func(key string, v *string) error {
|
||
if v == nil {
|
||
return nil
|
||
}
|
||
return set(key, *v)
|
||
}
|
||
|
||
if p := patch.Event; p != nil {
|
||
if err := setPtr("event.name", p.Name); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("event.subtitle", p.Subtitle); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("event.organizer", p.Host); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("event.logo_path", p.LogoPath); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
if p := patch.Display; p != nil {
|
||
if err := setPtr("display.background", p.Background); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("display.logo", p.Logo); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("display.title", p.Title); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("display.subtitle", p.Subtitle); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := setPtr("display.theme_color", p.PrimaryColor); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if p.RotateSeconds != nil {
|
||
// 前端 type=number 的 min/max 只是浏览器提示,服务端必须兜住,否则 1 或 9999 会落库。
|
||
if *p.RotateSeconds < minRotateSeconds || *p.RotateSeconds > maxRotateSeconds {
|
||
return dto.EventSettings{}, badRequest("display.rotateSeconds 需在 5~120 之间", *p.RotateSeconds)
|
||
}
|
||
if err := set("display.rotate_seconds", strconv.FormatFloat(*p.RotateSeconds, 'f', -1, 64)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
// 字体 id 必须在写入时就校验:引用一个不存在的字体,大屏只会静默回落成黑体,
|
||
// 现场看到的「配置没生效」比直接报错难排查得多。空串表示用默认字体。
|
||
if p.FontTitle != nil {
|
||
if err := s.checkDisplayFont("display.fontTitle", *p.FontTitle); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := set("display.font_title", strings.TrimSpace(*p.FontTitle)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
if p.FontBody != nil {
|
||
if err := s.checkDisplayFont("display.fontBody", *p.FontBody); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := set("display.font_body", strings.TrimSpace(*p.FontBody)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
// 实时赛况的当前项目:最多 2 个,且必须是真实存在的小项。
|
||
// 存不存在的 id 只会让大屏整块空白,现场还得回管理端排查,不如写入时就拒绝。
|
||
if p.LiveEvents != nil {
|
||
if len(*p.LiveEvents) > dto.MaxLiveEvents {
|
||
return dto.EventSettings{}, badRequest(fmt.Sprintf("display.liveEvents 最多 %d 个", dto.MaxLiveEvents), len(*p.LiveEvents))
|
||
}
|
||
ids := dedupeIDs(*p.LiveEvents)
|
||
for _, id := range ids {
|
||
var exists int
|
||
err := tx.QueryRowContext(ctx, `SELECT 1 FROM events WHERE id = ?`, id).Scan(&exists)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return dto.EventSettings{}, badRequest("display.liveEvents 引用了不存在的小项", id)
|
||
}
|
||
if err != nil {
|
||
return dto.EventSettings{}, internal(err)
|
||
}
|
||
}
|
||
if err := set(dto.SettingKeyLiveEvents, dto.FormatLiveEventIDs(ids)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
if p.LiveSource != nil {
|
||
source := dto.NormalizeLiveSource(*p.LiveSource)
|
||
if source == "" {
|
||
return dto.EventSettings{}, badRequest("display.liveSource 只能是 manual / auto", *p.LiveSource)
|
||
}
|
||
if err := set(dto.SettingKeyLiveSource, source); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
}
|
||
if p := patch.Standings; p != nil {
|
||
if p.IndividualToTeamWeight != nil {
|
||
// 0 与负数会被 domain 按 1 处理,等于静默忽略用户配置,直接拒绝更明确。
|
||
if *p.IndividualToTeamWeight <= 0 {
|
||
return dto.EventSettings{}, badRequest("standings.individualToTeamWeight 必须大于 0", *p.IndividualToTeamWeight)
|
||
}
|
||
if err := set("standings.individual_to_team_weight",
|
||
strconv.FormatFloat(*p.IndividualToTeamWeight, 'f', -1, 64)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
if err := setPtr("standings.mode", p.RankingMode); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if p.ShowIndividualBoard != nil {
|
||
if err := set("standings.show_individual_board", strconv.FormatBool(*p.ShowIndividualBoard)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
if p.ShowIndividualMedals != nil {
|
||
if err := set("standings.show_individual_medals", strconv.FormatBool(*p.ShowIndividualMedals)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
if p.LiveOnPublish != nil {
|
||
if err := set(dto.SettingKeyLiveOnPublish, strconv.FormatBool(*p.LiveOnPublish)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
}
|
||
if patch.DefaultProfileID != nil {
|
||
code := ""
|
||
if *patch.DefaultProfileID != 0 {
|
||
if err := tx.QueryRowContext(ctx, `SELECT code FROM scoring_profiles WHERE id = ?`, *patch.DefaultProfileID).Scan(&code); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return dto.EventSettings{}, badRequest("defaultProfileId 不存在", nil)
|
||
}
|
||
return dto.EventSettings{}, internal(err)
|
||
}
|
||
}
|
||
if err := set("scoring.default_profile", code); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
if patch.RequireApproval != nil {
|
||
if err := set("results.review_required", strconv.FormatBool(*patch.RequireApproval)); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
}
|
||
|
||
// 配置写操作必须留一条 change_log,否则 broadcastChanges 推空、大屏的标题/主题/榜单口径不刷新。
|
||
snapshot, err := s.settingsSnapshotQ(ctx, tx)
|
||
if err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, middleware.User(ctx).ID); err != nil {
|
||
return dto.EventSettings{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return dto.EventSettings{}, internal(err)
|
||
}
|
||
return snapshot, nil
|
||
}
|
||
|
||
// 大屏轮播间隔的取值范围,与 EventInfoPanel 的输入约束一致。
|
||
const (
|
||
minRotateSeconds = 5
|
||
maxRotateSeconds = 120
|
||
)
|
||
|
||
func atofDefault(v string, def float64) float64 {
|
||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||
if err != nil {
|
||
return def
|
||
}
|
||
return n
|
||
}
|
||
|
||
func boolDefault(v string, def bool) bool {
|
||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||
case "true", "1", "yes":
|
||
return true
|
||
case "false", "0", "no":
|
||
return false
|
||
default:
|
||
return def
|
||
}
|
||
}
|
||
|
||
// ---------- 系统级设置:登录有效期 ----------
|
||
//
|
||
// 存在系统库 system.db 的 settings(键 security.session_hours),与赛事级的
|
||
// /api/settings(event.db,随赛事包走)不共用:会话寿命是这台机器的属性,
|
||
// 切换到另一个赛事项目时不该跟着变。
|
||
|
||
func (s *server) getSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
return s.sessionSettings(r)
|
||
}
|
||
|
||
func (s *server) updateSessionSettingsHandler(w http.ResponseWriter, r *http.Request) (any, error) {
|
||
var in sessionSettingsPatch
|
||
if err := decodeJSON(r, &in); err != nil {
|
||
return nil, err
|
||
}
|
||
if in.SessionHours == nil {
|
||
return nil, badRequest("登录有效期 sessionHours 必填", nil)
|
||
}
|
||
hours := *in.SessionHours
|
||
if hours < auth.MinSessionHours || hours > auth.MaxSessionHours {
|
||
return nil, badRequest(
|
||
fmt.Sprintf("登录有效期(小时)需在 %d~%d 之间", auth.MinSessionHours, auth.MaxSessionHours), hours)
|
||
}
|
||
return nil, s.idempotent(w, r, in.RequestID, func() (any, error) {
|
||
if err := s.writeSessionHours(r.Context(), hours); err != nil {
|
||
return nil, err
|
||
}
|
||
s.audit(r.Context(), "settings.session_hours", "system", 0, nil,
|
||
map[string]any{"sessionHours": hours})
|
||
return s.sessionSettings(r)
|
||
})
|
||
}
|
||
|
||
// sessionSettings 组装面板要的登录有效期:生效值 + 取值范围 + 当前会话到期时间。
|
||
func (s *server) sessionSettings(r *http.Request) (dto.SessionSettings, error) {
|
||
values, err := loadSettingsMap(r.Context(), s.sys)
|
||
if err != nil {
|
||
return dto.SessionSettings{}, err
|
||
}
|
||
return dto.SessionSettings{
|
||
SessionHours: auth.EffectiveSessionHours(values),
|
||
DefaultHours: auth.DefaultSessionHours,
|
||
MinHours: auth.MinSessionHours,
|
||
MaxHours: auth.MaxSessionHours,
|
||
ExpiresAt: s.sessionExpiresAt(r),
|
||
}, nil
|
||
}
|
||
|
||
// sessionExpiresAt 取当前请求所用会话的到期时间(RFC3339 UTC);没有有效会话时为 nil。
|
||
// 前端要把它按本机时区显示成「当前会话将于 … 过期」,所以必须带时区,不能下发库里的裸时间串。
|
||
func (s *server) sessionExpiresAt(r *http.Request) *string {
|
||
c, err := r.Cookie(auth.CookieSession)
|
||
if err != nil || c.Value == "" {
|
||
return nil
|
||
}
|
||
_, sess, err := s.auth.Lookup(r.Context(), c.Value)
|
||
if err != nil || sess == nil {
|
||
return nil
|
||
}
|
||
v := sess.ExpiresAt.UTC().Format(time.RFC3339)
|
||
return &v
|
||
}
|
||
|
||
// writeSessionHours 落库登录有效期并立刻在进程内生效。
|
||
// 只写配置、不动任何已存在会话的 expires_at:改设置不该把正在录入的人踢下线。
|
||
func (s *server) writeSessionHours(ctx context.Context, hours int) error {
|
||
tx, err := s.sys.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
upsert := func(key, value string) error {
|
||
_, err := tx.ExecContext(ctx,
|
||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||
key, value)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
return nil
|
||
}
|
||
if err := upsert(auth.SessionHoursKey, strconv.Itoa(hours)); err != nil {
|
||
return err
|
||
}
|
||
// 记下这个值是面板写的:读取时就不再把它当成迁移种下的历史占位值,
|
||
// 操作员特意选的 12 才不会在重启后被悄悄改成默认的 24。
|
||
if err := upsert(auth.SessionHoursSourceKey, auth.SessionHoursSourcePanel); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return internal(err)
|
||
}
|
||
// 重启才生效会让现场以为「改了面板没反应」,所以同一请求里就换掉进程内的有效期。
|
||
s.auth.SetSessionTTL(time.Duration(hours) * time.Hour)
|
||
return nil
|
||
}
|
||
|
||
// ---------- 大屏公告 ----------
|
||
|
||
// 公告由大屏指令写进 settings 表:POST /api/display/command 允许 recorder,
|
||
// 而 PUT /api/settings 是 admin-only,公告的持久化只能走指令这一侧。
|
||
const (
|
||
announceTextKey = "display.announce_text"
|
||
announceSecondsKey = "display.announce_seconds"
|
||
defaultAnnounceSeconds = 20
|
||
)
|
||
|
||
// saveAnnouncement 落库当前公告正文与全屏推送时长;text 为空串即「无公告」。
|
||
func (s *server) saveAnnouncement(ctx context.Context, text string, seconds int, actorID int64) error {
|
||
if seconds <= 0 {
|
||
seconds = defaultAnnounceSeconds
|
||
}
|
||
return s.writeAnnouncement(ctx, actorID, announceTextKey, text, announceSecondsKey, strconv.Itoa(seconds))
|
||
}
|
||
|
||
// clearAnnouncement 清空当前公告;全屏时长保留,供下次推送沿用。
|
||
func (s *server) clearAnnouncement(ctx context.Context, actorID int64) error {
|
||
return s.writeAnnouncement(ctx, actorID, announceTextKey, "")
|
||
}
|
||
|
||
// writeAnnouncement 在一个事务内写入 settings 键值对(k1, v1, k2, v2 …)并登记一条 settings 变更。
|
||
// 只写 settings 行不会产生广播,已连接的大屏要靠 change_log 里的这条变更重拉快照才能看到公告变化。
|
||
func (s *server) writeAnnouncement(ctx context.Context, actorID int64, kv ...string) error {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
for i := 0; i+1 < len(kv); i += 2 {
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
|
||
kv[i], kv[i+1]); err != nil {
|
||
return internal(err)
|
||
}
|
||
}
|
||
snapshot, err := s.settingsSnapshotQ(ctx, tx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := bumpVersion(ctx, tx, "settings", 0, "upsert", snapshot, actorID); err != nil {
|
||
return err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return internal(err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------- standings ----------
|
||
|
||
func (s *server) loadStandingsInput(ctx context.Context, q queryer) (domain.StandingsInput, error) {
|
||
teams, err := s.domainTeams(ctx, q)
|
||
if err != nil {
|
||
return domain.StandingsInput{}, err
|
||
}
|
||
athletes, err := s.domainAthletes(ctx, q)
|
||
if err != nil {
|
||
return domain.StandingsInput{}, err
|
||
}
|
||
ledger, err := s.activeLedger(ctx, q)
|
||
if err != nil {
|
||
return domain.StandingsInput{}, err
|
||
}
|
||
ranked, err := s.buildRanked(ctx, q)
|
||
if err != nil {
|
||
return domain.StandingsInput{}, err
|
||
}
|
||
values, err := loadSettingsMap(ctx, q)
|
||
if err != nil {
|
||
return domain.StandingsInput{}, err
|
||
}
|
||
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
|
||
exclude := !boolDefault(values["standings.medal_counts_team_events"], true)
|
||
|
||
return domain.StandingsInput{
|
||
Teams: teams,
|
||
Athletes: athletes,
|
||
Ledger: ledger,
|
||
Ranked: ranked,
|
||
TieBreak: splitSettingList(values["standings.tie_break"]),
|
||
IndividualToTeamWeight: weight,
|
||
ExcludeTeamEventMedals: exclude,
|
||
}, nil
|
||
}
|
||
|
||
// splitSettingList 拆分 settings 里的逗号分隔列表(如 standings.tie_break),
|
||
// 口径与 rules 包对同一键的解析一致:去空白、丢空项,空值返回 nil 由 domain 落回默认序。
|
||
func splitSettingList(raw string) []string {
|
||
if strings.TrimSpace(raw) == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(raw, ",")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if p = strings.TrimSpace(p); p != "" {
|
||
out = append(out, p)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单,
|
||
// 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。
|
||
func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) {
|
||
in, err := s.loadStandingsInput(ctx, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
teams, err := teamMapQ(ctx, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil
|
||
}
|
||
|
||
// domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。
|
||
// 这里用全量单位表(含停用单位):榜单兜底会带上流水里的停用单位,否则编码/名称会为空。
|
||
func domainTeamsFromMap(teams map[int64]teamDTO) []domain.Team {
|
||
out := make([]domain.Team, 0, len(teams))
|
||
for _, t := range teams {
|
||
out = append(out, domain.Team{
|
||
ID: t.ID,
|
||
Code: t.Code,
|
||
Name: t.Name,
|
||
ShortName: strValue(t.ShortName),
|
||
LogoPath: strValue(t.LogoPath),
|
||
Color: strValue(t.Color),
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func strValue(s *string) string {
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
return *s
|
||
}
|
||
|
||
// bumpStandings 在写事务内把整张榜单写入 change_log;榜单变化后必须调用,否则大屏不会刷新。
|
||
func (s *server) bumpStandings(ctx context.Context, tx *sql.Tx, actorID int64) error {
|
||
snapshot, err := s.standingsSnapshot(ctx, tx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return bumpVersion(ctx, tx, "standings", 0, "replace", snapshot, actorID)
|
||
}
|
||
|
||
func (s *server) domainTeams(ctx context.Context, q queryer) ([]domain.Team, error) {
|
||
rows, err := q.QueryContext(ctx,
|
||
`SELECT id, code, name, short_name, color, sort_order, active FROM teams WHERE active = 1`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]domain.Team, 0)
|
||
for rows.Next() {
|
||
var (
|
||
t domain.Team
|
||
shortName sql.NullString
|
||
color sql.NullString
|
||
)
|
||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &shortName, &color, &t.SortOrder, &t.Active); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
t.ShortName = shortName.String
|
||
t.Color = color.String
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) domainAthletes(ctx context.Context, q queryer) ([]domain.Athlete, error) {
|
||
rows, err := q.QueryContext(ctx, `SELECT id, name, gender, team_id FROM athletes WHERE active = 1`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]domain.Athlete, 0)
|
||
for rows.Next() {
|
||
var (
|
||
a domain.Athlete
|
||
gender sql.NullString
|
||
teamID sql.NullInt64
|
||
)
|
||
if err := rows.Scan(&a.ID, &a.Name, &gender, &teamID); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
a.Gender = gender.String
|
||
a.TeamID = teamID.Int64
|
||
out = append(out, a)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// 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'
|
||
AND (c.scope = 'team' OR e.athlete_id IS NOT NULL)`)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]domain.RankedResult, 0)
|
||
for rows.Next() {
|
||
var (
|
||
rr domain.RankedResult
|
||
value sql.NullFloat64
|
||
athleteID sql.NullInt64
|
||
rank int
|
||
)
|
||
if err := rows.Scan(&rr.ResultID, &rr.EventID, &rr.CategoryID, &rank, &value, &rr.Status,
|
||
&rr.EntryID, &rr.TeamID, &athleteID, &rr.AthleteName, &rr.Scope, &rr.ChannelID); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
rr.Rank = rank
|
||
if value.Valid {
|
||
v := value.Float64
|
||
rr.ResultValue = &v
|
||
}
|
||
rr.AthleteID = athleteID.Int64
|
||
rr.Shares = []domain.TeamShare{{TeamID: rr.TeamID, Weight: 1}}
|
||
out = append(out, rr)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdownDTO, error) {
|
||
team, err := s.getTeam(ctx, teamID)
|
||
if err != nil {
|
||
return teamBreakdownDTO{}, err
|
||
}
|
||
// 合计必须与总积分榜同口径:只算计入单位总分的流水,
|
||
// 其中仅记单位的直加,记到个人的乘折算系数。
|
||
values, err := loadSettingsMap(ctx, s.ev)
|
||
if err != nil {
|
||
return teamBreakdownDTO{}, err
|
||
}
|
||
weight := atofDefault(values["standings.individual_to_team_weight"], 1)
|
||
if weight <= 0 {
|
||
weight = 1
|
||
}
|
||
rows, err := s.ev.QueryContext(ctx,
|
||
`SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, cat.name,
|
||
l.athlete_id, a.name, r.rank, r.result_text, l.points, COALESCE(l.created_at, '')
|
||
FROM score_ledger l
|
||
LEFT JOIN events ev ON ev.id = l.event_id
|
||
LEFT JOIN entries e ON e.id = l.entry_id
|
||
LEFT JOIN event_categories cat ON cat.id = e.category_id
|
||
LEFT JOIN athletes a ON a.id = l.athlete_id
|
||
LEFT JOIN results r ON r.id = l.result_id
|
||
WHERE l.team_id = ? AND l.voided = 0 AND l.count_to_team = 1
|
||
ORDER BY l.id DESC`, teamID)
|
||
if err != nil {
|
||
return teamBreakdownDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}}
|
||
for rows.Next() {
|
||
var (
|
||
item breakdownItemDTO
|
||
eventID, athleteID sql.NullInt64
|
||
eventCode, eventName sql.NullString
|
||
categoryName, athleteName sql.NullString
|
||
rank sql.NullInt64
|
||
resultText sql.NullString
|
||
)
|
||
if err := rows.Scan(&item.LedgerID, &item.SourceType, &eventID, &eventCode, &eventName,
|
||
&categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt); err != nil {
|
||
return teamBreakdownDTO{}, internal(err)
|
||
}
|
||
item.EventID = i64Ptr(eventID)
|
||
item.EventCode = strPtr(eventCode)
|
||
item.EventName = strPtr(eventName)
|
||
item.CategoryName = strPtr(categoryName)
|
||
item.AthleteID = i64Ptr(athleteID)
|
||
item.AthleteName = strPtr(athleteName)
|
||
item.Rank = intPtr(rank)
|
||
item.ResultText = strPtr(resultText)
|
||
if item.AthleteID != nil && *item.AthleteID != 0 {
|
||
item.Points *= weight
|
||
}
|
||
out.Items = append(out.Items, item)
|
||
out.TotalPoints += item.Points
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ---------- records ----------
|
||
|
||
func (s *server) listRecords(ctx context.Context, eventID int64) ([]recordEntryDTO, error) {
|
||
query := `SELECT id, event_id, level, COALESCE(result_value, 0), COALESCE(result_text, ''),
|
||
holder_name, holder_team_id, achieved_at, is_current, source
|
||
FROM records WHERE is_current = 1`
|
||
args := []any{}
|
||
if eventID > 0 {
|
||
query += ` AND event_id = ?`
|
||
args = append(args, eventID)
|
||
}
|
||
query += ` ORDER BY event_id, level`
|
||
|
||
rows, err := s.ev.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
out := make([]recordEntryDTO, 0)
|
||
for rows.Next() {
|
||
var (
|
||
r recordEntryDTO
|
||
holderName sql.NullString
|
||
holderTeamID sql.NullInt64
|
||
achievedAt sql.NullString
|
||
)
|
||
if err := rows.Scan(&r.ID, &r.EventID, &r.Level, &r.ResultValue, &r.ResultText,
|
||
&holderName, &holderTeamID, &achievedAt, &r.IsCurrent, &r.Source); err != nil {
|
||
return nil, internal(err)
|
||
}
|
||
r.HolderName = strPtr(holderName)
|
||
r.HolderTeamID = i64Ptr(holderTeamID)
|
||
r.AchievedAt = strPtr(achievedAt)
|
||
out = append(out, r)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *server) createRecordBreak(ctx context.Context, in recordBreakInput, userID int64) (recordBreakDTO, error) {
|
||
if in.EventID == 0 || in.EntryID == 0 || in.Level == "" {
|
||
return recordBreakDTO{}, badRequest("eventId、entryId、level 必填", nil)
|
||
}
|
||
switch in.Level {
|
||
case "meet", "city", "province", "national":
|
||
default:
|
||
return recordBreakDTO{}, badRequest("level 非法", in.Level)
|
||
}
|
||
if err := s.validateRecordBreakEntry(ctx, in); err != nil {
|
||
return recordBreakDTO{}, err
|
||
}
|
||
res, err := s.ev.ExecContext(ctx,
|
||
`INSERT INTO record_breaks (event_id, entry_id, level, result_value, result_text, bonus_points, state)
|
||
VALUES (?, ?, ?, ?, ?, 0, 'pending')`,
|
||
in.EventID, in.EntryID, in.Level, in.ResultValue, in.ResultText)
|
||
if isForeignKeyViolation(err) {
|
||
return recordBreakDTO{}, badRequest("eventId/entryId 不存在", nil)
|
||
}
|
||
if err != nil {
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
id, err := lastInsertID(res)
|
||
if err != nil {
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
return s.getRecordBreak(ctx, id)
|
||
}
|
||
|
||
// validateRecordBreakEntry 拦住「凭空加分」:报名项必须属于该小项、小项可破纪录、
|
||
// 且已有已发布且状态正常的成绩,否则确认后会直接写加分流水与纪录库。
|
||
func (s *server) validateRecordBreakEntry(ctx context.Context, in recordBreakInput) error {
|
||
var (
|
||
entryEvent int64
|
||
recordEligible bool
|
||
)
|
||
err := s.ev.QueryRowContext(ctx,
|
||
`SELECT e.event_id, ev.record_eligible FROM entries e JOIN events ev ON ev.id = e.event_id WHERE e.id = ?`,
|
||
in.EntryID).Scan(&entryEvent, &recordEligible)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return badRequest("entryId 不存在", in.EntryID)
|
||
}
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
if entryEvent != in.EventID {
|
||
return badRequest("entryId 不属于该小项", in.EntryID)
|
||
}
|
||
if !recordEligible {
|
||
return badRequest("该小项未开启破纪录登记", in.EventID)
|
||
}
|
||
var (
|
||
state string
|
||
status string
|
||
)
|
||
err = s.ev.QueryRowContext(ctx, `SELECT state, status FROM results WHERE entry_id = ?`, in.EntryID).Scan(&state, &status)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return badRequest("该报名项尚无成绩,不能登记破纪录", in.EntryID)
|
||
}
|
||
if err != nil {
|
||
return internal(err)
|
||
}
|
||
if state != string(domain.ResultStatePublished) || status != string(domain.ResultStatusOK) {
|
||
return badRequest("该成绩尚未发布或状态异常,不能登记破纪录", in.EntryID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *server) getRecordBreak(ctx context.Context, id int64) (recordBreakDTO, error) {
|
||
var (
|
||
b recordBreakDTO
|
||
value sql.NullFloat64
|
||
text sql.NullString
|
||
approvedBy sql.NullInt64
|
||
approvedAt sql.NullString
|
||
)
|
||
err := s.ev.QueryRowContext(ctx,
|
||
`SELECT id, event_id, entry_id, level, result_value, result_text, bonus_points, state, approved_by, approved_at
|
||
FROM record_breaks WHERE id = ?`, id).
|
||
Scan(&b.ID, &b.EventID, &b.EntryID, &b.Level, &value, &text, &b.BonusPoints, &b.State, &approvedBy, &approvedAt)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||
}
|
||
if err != nil {
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
b.ResultValue = value.Float64
|
||
b.ResultText = text.String
|
||
b.ApprovedBy = i64Ptr(approvedBy)
|
||
b.ApprovedAt = strPtr(approvedAt)
|
||
return b, nil
|
||
}
|
||
|
||
func (s *server) approveRecordBreak(ctx context.Context, id, userID int64) (recordBreakDTO, error) {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
var (
|
||
state string
|
||
eventID int64
|
||
entryID int64
|
||
level string
|
||
resultVal sql.NullFloat64
|
||
resultText sql.NullString
|
||
)
|
||
if err := tx.QueryRowContext(ctx,
|
||
`SELECT state, event_id, entry_id, level, result_value, result_text FROM record_breaks WHERE id = ?`, id).
|
||
Scan(&state, &eventID, &entryID, &level, &resultVal, &resultText); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return recordBreakDTO{}, notFound("破纪录登记不存在")
|
||
}
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
if state != "pending" {
|
||
return recordBreakDTO{}, stateInvalid("该破纪录登记已处理")
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE record_breaks SET state = 'approved', approved_by = ?, approved_at = datetime('now') WHERE id = ?`,
|
||
nullID(userID), id); err != nil {
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
if _, err := recomputeLedgerTx(ctx, tx, eventID, 0, userID, "确认破纪录加分"); err != nil {
|
||
return recordBreakDTO{}, err
|
||
}
|
||
// 确认即更新纪录库,并把引擎算出的加分回填到登记上(devdocs/02 第 3 节)。
|
||
if err := upsertCurrentRecord(ctx, tx, eventID, entryID, level, resultVal, resultText); err != nil {
|
||
return recordBreakDTO{}, err
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE record_breaks SET bonus_points = COALESCE((
|
||
SELECT SUM(points) FROM score_ledger
|
||
WHERE source_type = 'record_bonus' AND event_id = ? AND entry_id = ? AND voided = 0), 0)
|
||
WHERE id = ?`, eventID, entryID, id); err != nil {
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
if err := bumpVersion(ctx, tx, "record", id, "upsert", map[string]any{"id": id}, userID); err != nil {
|
||
return recordBreakDTO{}, err
|
||
}
|
||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||
return recordBreakDTO{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return recordBreakDTO{}, internal(err)
|
||
}
|
||
s.audit(ctx, "record_break.approve", "record_break", id, map[string]any{"state": state}, map[string]any{"state": "approved"})
|
||
return s.getRecordBreak(ctx, id)
|
||
}
|
||
|
||
// upsertCurrentRecord 把确认后的破纪录写入 records:同小项同档位的旧纪录改为非当前,
|
||
// 新纪录标为 current(devdocs/04 records 表;source=current 表示本届产生)。
|
||
func upsertCurrentRecord(ctx context.Context, tx *sql.Tx, eventID, entryID int64, level string, value sql.NullFloat64, text sql.NullString) error {
|
||
var (
|
||
teamID int64
|
||
athleteID sql.NullInt64
|
||
athleteName sql.NullString
|
||
teamName sql.NullString
|
||
)
|
||
if err := tx.QueryRowContext(ctx,
|
||
`SELECT e.team_id, e.athlete_id, a.name, t.name
|
||
FROM entries e
|
||
LEFT JOIN athletes a ON a.id = e.athlete_id
|
||
LEFT JOIN teams t ON t.id = e.team_id
|
||
WHERE e.id = ?`, entryID).Scan(&teamID, &athleteID, &athleteName, &teamName); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return notFound("报名项不存在")
|
||
}
|
||
return internal(err)
|
||
}
|
||
holder := teamName.String
|
||
if athleteID.Valid && athleteName.String != "" {
|
||
holder = athleteName.String
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE records SET is_current = 0 WHERE event_id = ? AND level = ? AND is_current = 1`,
|
||
eventID, level); err != nil {
|
||
return internal(err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO records (event_id, level, result_value, result_text, holder_name, holder_team_id, achieved_at, is_current, source)
|
||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), 1, 'current')`,
|
||
eventID, level, value, text, nullStr(&holder), nullID(teamID)); err != nil {
|
||
return internal(err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---------- adjustments ----------
|
||
|
||
func (s *server) listAdjustments(ctx context.Context, teamID int64, page, size int) (pageResult[adjustmentDTO], error) {
|
||
where := []string{"1 = 1"}
|
||
args := []any{}
|
||
if teamID > 0 {
|
||
where = append(where, "team_id = ?")
|
||
args = append(args, teamID)
|
||
}
|
||
clause := " WHERE " + joinAnd(where)
|
||
|
||
var total int
|
||
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM point_adjustments`+clause, args...).Scan(&total); err != nil {
|
||
return pageResult[adjustmentDTO]{}, internal(err)
|
||
}
|
||
query := `SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
|
||
FROM point_adjustments` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
|
||
args = append(args, size, (page-1)*size)
|
||
|
||
rows, err := s.ev.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return pageResult[adjustmentDTO]{}, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
items := make([]adjustmentDTO, 0)
|
||
for rows.Next() {
|
||
var (
|
||
a adjustmentDTO
|
||
eventID, createdBy sql.NullInt64
|
||
)
|
||
if err := rows.Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt); err != nil {
|
||
return pageResult[adjustmentDTO]{}, internal(err)
|
||
}
|
||
a.EventID = i64Ptr(eventID)
|
||
a.CreatedBy = i64Ptr(createdBy)
|
||
items = append(items, a)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return pageResult[adjustmentDTO]{}, internal(err)
|
||
}
|
||
return pageResult[adjustmentDTO]{Items: items, Total: total, Page: page, Size: size}, nil
|
||
}
|
||
|
||
func (s *server) createAdjustment(ctx context.Context, in adjustmentInput, userID int64) (adjustmentDTO, error) {
|
||
values, err := loadSettingsMap(ctx, s.ev)
|
||
if err != nil {
|
||
return adjustmentDTO{}, err
|
||
}
|
||
if !boolDefault(values["manual_adjustment.enabled"], false) {
|
||
return adjustmentDTO{}, stateInvalid("人工调整当前未启用")
|
||
}
|
||
if boolDefault(values["manual_adjustment.reason_required"], true) && strings.TrimSpace(in.Reason) == "" {
|
||
return adjustmentDTO{}, badRequest("reason 必填", nil)
|
||
}
|
||
switch in.Category {
|
||
case "penalty", "bonus", "correction":
|
||
default:
|
||
return adjustmentDTO{}, badRequest("category 非法", in.Category)
|
||
}
|
||
if in.TeamID == 0 {
|
||
return adjustmentDTO{}, badRequest("teamId 必填", nil)
|
||
}
|
||
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
res, err := tx.ExecContext(ctx,
|
||
`INSERT INTO point_adjustments (team_id, delta, category, reason, event_id, created_by)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
in.TeamID, in.Delta, in.Category, in.Reason, nullI64(in.EventID), nullID(userID))
|
||
if isForeignKeyViolation(err) {
|
||
return adjustmentDTO{}, badRequest("teamId/eventId 不存在", nil)
|
||
}
|
||
if err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
id, err := lastInsertID(res)
|
||
if err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`INSERT INTO score_ledger (team_id, event_id, source_type, source_id, points, created_by)
|
||
VALUES (?, ?, 'adjustment', ?, ?, ?)`,
|
||
in.TeamID, nullI64(in.EventID), id, in.Delta, nullID(userID)); err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
// 调整分进积分榜,必须写版本号,否则大屏不会刷新。
|
||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||
return adjustmentDTO{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
s.audit(ctx, "adjustment.create", "adjustment", id, nil, in)
|
||
return s.getAdjustment(ctx, id)
|
||
}
|
||
|
||
func (s *server) getAdjustment(ctx context.Context, id int64) (adjustmentDTO, error) {
|
||
var (
|
||
a adjustmentDTO
|
||
eventID, createdBy sql.NullInt64
|
||
)
|
||
err := s.ev.QueryRowContext(ctx,
|
||
`SELECT id, team_id, delta, category, reason, event_id, voided, created_by, created_at
|
||
FROM point_adjustments WHERE id = ?`, id).
|
||
Scan(&a.ID, &a.TeamID, &a.Delta, &a.Category, &a.Reason, &eventID, &a.Voided, &createdBy, &a.CreatedAt)
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return adjustmentDTO{}, notFound("人工调整不存在")
|
||
}
|
||
if err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
a.EventID = i64Ptr(eventID)
|
||
a.CreatedBy = i64Ptr(createdBy)
|
||
return a, nil
|
||
}
|
||
|
||
func (s *server) voidAdjustment(ctx context.Context, id, userID int64, reason string) (adjustmentDTO, error) {
|
||
tx, err := s.ev.BeginTx(ctx, nil)
|
||
if err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
defer func() { _ = tx.Rollback() }()
|
||
|
||
var voided bool
|
||
if err := tx.QueryRowContext(ctx, `SELECT voided FROM point_adjustments WHERE id = ?`, id).Scan(&voided); err != nil {
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
return adjustmentDTO{}, notFound("人工调整不存在")
|
||
}
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
if voided {
|
||
return adjustmentDTO{}, stateInvalid("该人工调整已作废")
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE point_adjustments SET voided = 1, voided_by = ?, voided_at = datetime('now') WHERE id = ?`,
|
||
nullID(userID), id); err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
if _, err := tx.ExecContext(ctx,
|
||
`UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ?
|
||
WHERE source_type = 'adjustment' AND source_id = ? AND voided = 0`,
|
||
nullID(userID), reason, id); err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
if err := s.bumpStandings(ctx, tx, userID); err != nil {
|
||
return adjustmentDTO{}, err
|
||
}
|
||
if err := tx.Commit(); err != nil {
|
||
return adjustmentDTO{}, internal(err)
|
||
}
|
||
s.audit(ctx, "adjustment.void", "adjustment", id, map[string]any{"voided": false}, map[string]any{"voided": true, "reason": reason})
|
||
return s.getAdjustment(ctx, id)
|
||
}
|
||
|
||
// ---------- audit logs ----------
|
||
|
||
// listAuditLogs 支持 entityType / userId / action 三个过滤条件,任意组合。
|
||
// action 按子串模糊匹配:现场找的是「某类动作」(如 team.、result.publish),
|
||
// 精确等值会逼操作员背全名。三个条件都进同一条 WHERE,组合时是 AND。
|
||
func (s *server) listAuditLogs(ctx context.Context, page, size int, entityType string, userID int64, action string) (pageResult[auditLogDTO], error) {
|
||
where := []string{"1 = 1"}
|
||
args := []any{}
|
||
if entityType != "" {
|
||
where = append(where, "entity_type = ?")
|
||
args = append(args, entityType)
|
||
}
|
||
if userID > 0 {
|
||
where = append(where, "user_id = ?")
|
||
args = append(args, userID)
|
||
}
|
||
if action != "" {
|
||
// 转义 % / _ / \ 让它们按字面量匹配,避免用户输入被当成通配符。
|
||
where = append(where, `action LIKE ? ESCAPE '\'`)
|
||
args = append(args, "%"+escapeLike(action)+"%")
|
||
}
|
||
clause := " WHERE " + joinAnd(where)
|
||
|
||
var total int
|
||
if err := s.ev.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_logs`+clause, args...).Scan(&total); err != nil {
|
||
return pageResult[auditLogDTO]{}, internal(err)
|
||
}
|
||
query := `SELECT id, user_id, action, COALESCE(entity_type, ''), entity_id, before_json, after_json, ip, request_id, created_at
|
||
FROM audit_logs` + clause + ` ORDER BY id DESC LIMIT ? OFFSET ?`
|
||
args = append(args, size, (page-1)*size)
|
||
|
||
rows, err := s.ev.QueryContext(ctx, query, args...)
|
||
if err != nil {
|
||
return pageResult[auditLogDTO]{}, internal(err)
|
||
}
|
||
defer func() { _ = rows.Close() }()
|
||
|
||
items := make([]auditLogDTO, 0)
|
||
for rows.Next() {
|
||
var (
|
||
a auditLogDTO
|
||
uid, eid sql.NullInt64
|
||
before sql.NullString
|
||
after sql.NullString
|
||
ip, reqID sql.NullString
|
||
)
|
||
if err := rows.Scan(&a.ID, &uid, &a.Action, &a.EntityType, &eid, &before, &after, &ip, &reqID, &a.CreatedAt); err != nil {
|
||
return pageResult[auditLogDTO]{}, internal(err)
|
||
}
|
||
a.UserID = i64Ptr(uid)
|
||
a.EntityID = i64Ptr(eid)
|
||
a.BeforeJSON = strPtr(before)
|
||
a.AfterJSON = strPtr(after)
|
||
a.IP = strPtr(ip)
|
||
a.RequestID = strPtr(reqID)
|
||
items = append(items, a)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return pageResult[auditLogDTO]{}, internal(err)
|
||
}
|
||
return pageResult[auditLogDTO]{Items: items, Total: total, Page: page, Size: size}, nil
|
||
}
|
||
|
||
// escapeLike 把 LIKE 的通配符转义为字面量,配合 ESCAPE '\' 使用。
|
||
func escapeLike(s string) string {
|
||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||
}
|
||
|
||
// ---------- backup ----------
|
||
|
||
// backup 生成系统库与赛事库的一致性快照(见 devdocs/08 第 6 节)。
|
||
// 手动备份写审计;定时备份不写(见 export.Service.StartAuto 的说明)。
|
||
func (s *server) backup(ctx context.Context) (backupResultDTO, error) {
|
||
infos, err := s.backups.Run("manual")
|
||
if err != nil {
|
||
if errors.Is(err, export.ErrNoDir) {
|
||
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
|
||
}
|
||
return backupResultDTO{}, internal(err)
|
||
}
|
||
if len(infos) == 0 {
|
||
return backupResultDTO{}, internal(errors.New("未配置数据目录"))
|
||
}
|
||
s.audit(ctx, "system.backup", "backup", 0, nil, map[string]any{"path": infos[0].Path})
|
||
return backupResultDTO{Path: infos[0].Path, CreatedAt: infos[0].CreatedAt.UTC().Format(time.RFC3339)}, nil
|
||
}
|