diff --git a/VERSION b/VERSION index 83c008d..8c13cbc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -dev_0.5.0 +dev_0.5.1 diff --git a/console/installer/art-gen/gen_art.py b/console/installer/art-gen/gen_art.py new file mode 100644 index 0000000..cd7fa15 --- /dev/null +++ b/console/installer/art-gen/gen_art.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""自绘安装器的位图素材生成器(构建期工具,产物随仓库一起提交)。 + +为什么要有这个脚本:安装器窗口是 NSIS 自绘的无边框窗口(760×480),界面本身就是一堆 +钉死在固定坐标上的位图,NSIS 只负责「把图贴到点上」。窗口尺寸、文案、配色每改一次就要 +重出一整套图,手工导图必然会出 1x/2x 不同步、半像素发虚、缺字形变方框这些问题,所以 +整套素材全部由代码生成。 + +用法: + python3 console/installer/art-gen/gen_art.py + +产出: + console/installer/art/1x/ 常规 DPI 用 + console/installer/art/2x/ 4K@200% 缩放用(同一套常量乘 2) + debug/安装界面预览/06-五页预览.png 人眼验收用的五页联络图(不参与打包) + +改了设计就重跑本脚本,不要手改 art/ 里的位图:改图不改脚本,下次重跑就被覆盖, +而且 1x/2x 会悄悄失去同步。 + +几条硬约定: + - 所有坐标/尺寸都按 1x 设计稿书写,2x 由同一套常量乘 2 得到,不写第二套数值; + - 「✕ / — / ✓ / 圆点」这类符号在 Noto Sans SC 里没有字形,一律用线段和圆画出来; + - 字体只用 noto-sans/sc 目录下的简体字重,tc(繁体)目录缺简体字形,会把字渲染成方框。 +""" + +import os + +from PIL import Image, ImageDraw, ImageFont + +# ---------------------------------------------------------------- 路径 + +HERE = os.path.dirname(os.path.abspath(__file__)) # console/installer/art-gen +REPO = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) # 仓库根 +ART_DIR = os.path.join(os.path.dirname(HERE), "art") # console/installer/art +CONTACT_SHEET = os.path.join(REPO, "debug", "安装界面预览", "06-五页预览.png") + +# 图标源文件放在仓库根,打包脚本和安装器素材共用同一份,避免出现两个版本的应用图标 +ICON_SRC = os.path.join(REPO, "Scoreboard.png") + +# 字体不在仓库里(三套字重加起来 25MB,装进仓库不划算),所以按本机绝对路径引用; +# 换机器构建时把这个常量改掉即可,字体缺失会在 font() 里直接报错而不是悄悄退化 +FONT_DIR = "/home/Aska/Fonts/noto-sans/sc" + + +def repo_version(): + """读仓库根 VERSION(版本号唯一真相),去掉 dev_ 前缀给安装包显示用。""" + with open(os.path.join(REPO, "VERSION"), encoding="utf-8-sig") as fh: + return (fh.readline().strip() or "0.0.0").removeprefix("dev_") + +# ---------------------------------------------------------------- 配色 + +NAVY = "#0b1e3a" # 标题栏 / 品牌栏渐变起点 +NAVY_END = "#16406f" # 品牌栏渐变终点 +CONTENT_BG = "#f6f9fd" # 右侧内容区底色 +TXT = "#16233a" # 正文深色 +TXT_DIM = "#5a6b85" # 次要文字 +BRAND_WEAK = "#8ba6c6" # 品牌栏弱文字 +BRAND_WEAKER = "#6b86a8" # 品牌栏最弱(版本号) +PRIMARY = "#2f9bf0" # 主色 +PRIMARY_HOVER = "#3fa9f5" +PRIMARY_PRESSED = "#1f86d8" +PRIMARY_SOFT = "#f0f6ff" # 次要按钮悬停底色 +PRIMARY_SOFT_DOWN = "#dce9fa" # 次要按钮按下底色 +CYAN = "#4fd8ff" # 品牌栏字标点缀 +BORDER = "#cfdcec" +DIVIDER = "#e3ebf5" +CLOSE_HOVER = "#e5484d" +CLOSE_PRESSED = "#c93b40" +SUCCESS = "#34c759" + +TB_DIVIDER = "#1e3e69" # 标题栏底部分隔线 +TB_TEXT = "#cfe3ff" +TB_MIN = "#96afcd" +TB_MIN_HOVER = "#cfe3ff" +TB_CLOSE = "#bed2eb" +TB_HOVER = "#1b2d4a" # 白色 8% 叠在海军蓝上的等效色 +TB_HOVER_DOWN = "#2d3e56" # 白色 14% 叠在海军蓝上 + +# ---------------------------------------------------------------- 版面 + +WIN_W, WIN_H = 760, 480 +TITLE_H = 40 +SIDE_W = 240 +BRAND_H = WIN_H - TITLE_H + +PAGE_X, PAGE_Y = SIDE_W, TITLE_H +PAGE_W, PAGE_H = WIN_W - SIDE_W, WIN_H - TITLE_H +PAD_L, PAD_T = 32, 28 +CONTENT_W = PAGE_W - PAD_L * 2 # 456 +DIVIDER_Y = 376 # 页面内按钮条的上边线 + +BTN_W, BTN_H = 108, 36 +BTN_Y = 416 +BTN_PRIMARY_X = 728 - BTN_W # 620,右边缘对齐内容区右边线 +BTN_SECONDARY_X = 608 - BTN_W # 500,与主按钮间距 12 + +TB_MIN_BOX = (672, 0, 44, 40) +TB_CLOSE_BOX = (716, 0, 44, 40) + +SS = 4 # 超采样倍数:PIL 画圆/圆角矩形没有抗锯齿,4 倍渲染再缩回来才有干净边缘 + +_font_cache = {} + + +def font(weight, size, k): + """取字重字体;size 是 1x 设计字号,k 是当前渲染倍率(= 输出倍率 × 超采样倍数)。""" + key = (weight, size, k) + if key not in _font_cache: + path = os.path.join(FONT_DIR, "NotoSansSC-%s.otf" % weight) + if not os.path.exists(path): + raise SystemExit("字体缺失:%s(本机请确认 noto-sans/sc 三个字重在位)" % path) + _font_cache[key] = ImageFont.truetype(path, size * k) + return _font_cache[key] + + +def U(k): + """返回 1x 设计坐标 → 渲染像素的换算函数。 + + 统一四舍五入:设计稿里存在 12.5、11.5 这类半像素字号和坐标,不取整会让 + 线条落在半像素上,缩回来变成两行灰边。 + """ + return lambda v: int(round(v * k)) + + +def down(im): + """超采样降采样,得到最终位图。""" + return im.resize((im.width // SS, im.height // SS), Image.LANCZOS) + + +def hgrad(w, h, c0, c1): + """横向线性渐变。品牌栏底色与品牌栏位图共用本函数,两者叠加才不会露出接缝。""" + a, b = ImageColor_rgb(c0), ImageColor_rgb(c1) + im = Image.new("RGB", (w, h)) + d = ImageDraw.Draw(im) + for x in range(w): + t = x / max(w - 1, 1) + d.line([(x, 0), (x, h)], fill=tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))) + return im + + +def ImageColor_rgb(s): + s = s.lstrip("#") + return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4)) + + +def box(p, x, y, w, h): + """1x 的 (x, y, 宽, 高) → PIL 的闭区间 bbox(PIL 的右下角是含在内的)。""" + return [p(x), p(y), p(x + w) - 1, p(y + h) - 1] + + +def text_top(d, p, x, y, s, f, fill): + """按「字形可见顶部」定位。 + + 中英文混排时 ascender 高度不一致(数字/英文比汉字矮),直接给 y 会让同一组的 + 几行文字看起来忽高忽低,所以按实际墨迹框归位。 + """ + b = d.textbbox((0, 0), s, font=f, anchor="la") + d.text((p(x), p(y) - b[1]), s, font=f, fill=fill, anchor="la") + + +def text_bottom(d, p, x, y, s, f, fill): + """按「字形可见底部」定位,用于版本号这种按底边距对齐的文字。""" + b = d.textbbox((0, 0), s, font=f, anchor="la") + d.text((p(x), p(y) - b[3]), s, font=f, fill=fill, anchor="la") + + +def text_center(d, p, cx, cy, s, f, fill): + """按墨迹框居中,用于按钮文字。""" + b = d.textbbox((0, 0), s, font=f) + d.text((p(cx) - (b[2] + b[0]) / 2, p(cy) - (b[3] + b[1]) / 2), s, font=f, fill=fill) + + +def dot(d, p, cx, cy, r, fill): + """画圆点。要点符号 fonts 里没有,只能画。""" + d.ellipse([p(cx - r), p(cy - r), p(cx + r), p(cy + r)], fill=fill) + + +def check(d, p, pts, color, width): + """对勾用折线画。✓ 字形在 Noto Sans SC 里缺,硬渲染会出方框。""" + d.line([(p(x), p(y)) for x, y in pts], fill=color, width=p(width), joint="curve") + + +def paste_icon(im, p, x, y, w, h): + """把应用图标等比缩放进 (x, y, w, h) 的方框并居中。 + + 源图自带圆角透明背景,必须带 alpha 贴;另外先裁掉四周透明留白,否则同一张图 + 在标题栏和品牌栏两处的视觉大小会对不上。 + """ + src = Image.open(ICON_SRC).convert("RGBA") + src = src.crop(src.getchannel("A").getbbox()) + tw, th = p(w), p(h) + r = min(tw / src.width, th / src.height) + nw, nh = max(1, int(round(src.width * r))), max(1, int(round(src.height * r))) + src = src.resize((nw, nh), Image.LANCZOS) + im.paste(src, (p(x) + (tw - nw) // 2, p(y) + (th - nh) // 2), src) + + +# ---------------------------------------------------------------- 背景与框架 + +def draw_base(k): + """底层:标题栏 + 品牌栏渐变 + 内容区底色。 + + 品牌栏渐变在这里也要画一遍(上面还会贴 brand.bmp),这样底图本身就是完整的, + 单独看 base.bmp 也不会露出一块空白。 + """ + p = U(k) + im = Image.new("RGB", (p(WIN_W), p(WIN_H)), CONTENT_BG) + im.paste(hgrad(p(SIDE_W), p(BRAND_H), NAVY, NAVY_END), (0, p(TITLE_H))) + d = ImageDraw.Draw(im) + d.rectangle(box(p, 0, 0, WIN_W, TITLE_H), fill=NAVY) + return down(im) + + +def draw_brand(k): + """品牌栏内容。底色是同一段渐变,贴在 base 的品牌栏上无缝。""" + p = U(k) + im = hgrad(p(SIDE_W), p(BRAND_H), NAVY, NAVY_END) + d = ImageDraw.Draw(im) + + # 图标 x 与下方文字的左边距(36)取齐,不留出那 12px 的错位 + paste_icon(im, p, 36, 48, 96, 96) + + text_top(d, p, 36, 172, "赛事积分榜", font("Bold", 26, k), "#ffffff") + text_top(d, p, 36, 219, "S C O R E B O A R D", font("Medium", 11, k), CYAN) + text_top(d, p, 36, 250, "一拖二 · 实时积分大屏", font("Regular", 12, k), BRAND_WEAK) + text_top(d, p, 36, 272, "裁判长管理面板", font("Regular", 12, k), BRAND_WEAK) + # 版本号按「左边距 36 / 底边距 40」定位,底部对齐比顶部对齐更稳 + # 版本从仓库根 VERSION 读(唯一真相):烘焙进位图意味着发新版必须重跑本脚本, + # deploy/prepare-extra.mjs 里有一道校验,版本对不上会直接让构建失败,不会悄悄发错版本 + text_bottom(d, p, 36, BRAND_H - 40, f"版本 v{repo_version()}", font("Regular", 11, k), BRAND_WEAKER) + return down(im) + + +def draw_titlebar(k): + """标题栏:图标 + 标题 + 右侧最小化/关闭。 + + 两个按钮图形按 44×40 按钮框居中画,和 tb-*.bmp 的图形位置严格一致 —— 悬停时 + NSIS 用按钮位图盖住这块区域,位置不一致的话图形会跳一下。 + """ + p = U(k) + im = Image.new("RGB", (p(WIN_W), p(TITLE_H)), NAVY) + d = ImageDraw.Draw(im) + + paste_icon(im, p, 14, 10, 20, 20) + text_top(d, p, 44, 12, "赛事积分榜 安装程序", font("Medium", 14, k), TB_TEXT) + + min_cx = TB_MIN_BOX[0] + TB_MIN_BOX[2] / 2 + close_cx = TB_CLOSE_BOX[0] + TB_CLOSE_BOX[2] / 2 + d.line([(p(min_cx - 7), p(20)), (p(min_cx + 7), p(20))], fill=TB_MIN, width=p(2)) + d.line([(p(close_cx - 6), p(14)), (p(close_cx + 6), p(26))], fill=TB_CLOSE, width=p(2)) + d.line([(p(close_cx + 6), p(14)), (p(close_cx - 6), p(26))], fill=TB_CLOSE, width=p(2)) + + # 底部分隔线画在标题栏位图内部(画在 base 上会被标题栏位图盖掉) + d.rectangle(box(p, 0, TITLE_H - 1, WIN_W, 1), fill=TB_DIVIDER) + return down(im) + + +# ---------------------------------------------------------------- 五个页面 + +def page_base(k): + """页面公共底:内容区底色 + 按钮条上边线。""" + p = U(k) + im = Image.new("RGB", (p(PAGE_W), p(PAGE_H)), CONTENT_BG) + d = ImageDraw.Draw(im) + d.rectangle(box(p, 0, DIVIDER_Y, PAGE_W, 1), fill=DIVIDER) + return im, d + + +def page_head(d, p, k, title, desc=None): + text_top(d, p, PAD_L, PAD_T, title, font("Bold", 20, k), TXT) + if desc: + text_top(d, p, PAD_L, 62, desc, font("Regular", 13, k), TXT_DIM) + + +def draw_page_welcome(k): + p = U(k) + im, d = page_base(k) + page_head(d, p, k, "欢迎安装", "本向导将把《赛事积分榜》安装到这台计算机。") + + items = [ + "一拖二部署:一个服务端,同时带实时积分大屏与裁判长管理面板", + "完全离线运行,不需要外网", + "数据与赛事配置存在当前用户目录,覆盖安装不会丢", + ] + f = font("Regular", 13, k) + y = 118 + for it in items: + b = d.textbbox((p(52), p(y)), it, font=f, anchor="la") + # 圆点对齐每行墨迹的垂直中点,行内英文/数字混排时也不会跑偏 + dot(d, p, PAD_L + 4, (b[1] + b[3]) / 2 / k, 4, PRIMARY) + d.text((p(52), p(y)), it, font=f, fill=TXT, anchor="la") + y += 32 + return down(im) + + +def draw_page_license(k): + p = U(k) + im, d = page_base(k) + page_head(d, p, k, "许可协议", "请阅读以下条款。继续安装表示你接受这些条款。") + + lines = [ + "本软件以 MIT 许可协议发布,你可以自由使用、修改和分发。", + "分发或再发布时,必须保留原始的版权声明与许可声明。", + "本软件按「现状」提供,不附带任何形式的明示或默示担保。", + "因使用本软件造成的任何损失,作者不承担任何责任。", + ] + f = font("Regular", 13, k) + y = 104 + for ln in lines: + text_top(d, p, PAD_L, y, ln, f, TXT) + y += 22 + + # 只画外框,里面的协议正文由真控件渲染(位图放不下整篇协议) + d.rectangle(box(p, PAD_L, 196, CONTENT_W, 176), outline=BORDER, width=p(1)) + return down(im) + + +def draw_page_dir(k): + p = U(k) + im, d = page_base(k) + page_head(d, p, k, "选择安装位置", "安装程序将把《赛事积分榜》装到下面的目录。") + + # 这一条是「目录输入行」的外框:框内左侧跑真编辑框,右侧 96×34 的「浏览…」 + # 按钮位图直接压在外框右端(框高 34 与按钮等高,所以按钮正好卡在框内) + d.rectangle(box(p, PAD_L, 110, CONTENT_W, 34), outline=BORDER, width=p(1)) + + text_top(d, p, PAD_L, 170, "安装模式", font("Medium", 13, k), TXT) + # 文字从 56 起排,左边 24px 留给单选钮位图 + text_top(d, p, 56, 222, "仅为我安装(不需要管理员权限)", font("Regular", 12.5, k), TXT) + text_top(d, p, 56, 262, "为所有用户安装(会弹 UAC,并自动放行防火墙 19540)", + font("Regular", 12.5, k), TXT) + return down(im) + + +def draw_page_progress(k): + p = U(k) + im, d = page_base(k) + # 进度页正文区留空:阶段文案和进度条都用独立位图贴上去(NSIS 换图=换状态) + page_head(d, p, k, "正在安装") + d.rounded_rectangle(box(p, PAD_L, 140, CONTENT_W, 8), radius=p(4), fill=DIVIDER) + return down(im) + + +def draw_page_finish(k): + p = U(k) + im, d = page_base(k) + page_head(d, p, k, "安装完成", "《赛事积分榜》已安装到这台计算机。") + + # 成功标记在内容区水平居中:绿实心圆 + 白对勾 + cx = PAGE_W / 2 + cy = 164 + d.ellipse([p(cx - 24), p(cy - 24), p(cx + 24), p(cy + 24)], fill=SUCCESS) + check(d, p, [(cx - 11, cy + 0.5), (cx - 3, cy + 8.5), (cx + 12, cy - 8.5)], "#ffffff", 3.5) + + # 勾选项文字从 56 起排,左边 24px 留给勾选框位图 + text_top(d, p, 56, 300, "立即运行 赛事积分榜", font("Regular", 13, k), TXT) + text_top(d, p, PAD_L, 336, "安装位置:D:\\Program Files\\Scoreboard_Console", + font("Regular", 11.5, k), TXT_DIM) + return down(im) + + +# ---------------------------------------------------------------- 按钮 + +def draw_button(k, label, w, h, kind, state): + """圆角按钮三态。圆角外填内容区底色,贴到按钮条上才不会露出白角。""" + p = U(k) + im = Image.new("RGB", (p(w), p(h)), CONTENT_BG) + d = ImageDraw.Draw(im) + if kind == "primary": + bg = {"normal": PRIMARY, "hover": PRIMARY_HOVER, "pressed": PRIMARY_PRESSED}[state] + fg, border = "#ffffff", None + else: + bg = {"normal": "#ffffff", "hover": PRIMARY_SOFT, "pressed": PRIMARY_SOFT_DOWN}[state] + border = BORDER if state == "normal" else PRIMARY + fg = TXT + d.rounded_rectangle([0, 0, p(w) - 1, p(h) - 1], radius=p(8), fill=bg, + outline=border, width=p(1) if border else 0) + text_center(d, p, w / 2, h / 2, label, font("Medium", 13, k), fg) + return down(im) + + +def draw_tb_button(k, kind, state): + """标题栏按钮。常态底色=标题栏底色,贴上去和标题栏融为一体。""" + p = U(k) + im = Image.new("RGB", (p(44), p(40)), NAVY) + d = ImageDraw.Draw(im) + if kind == "close": + bg = {"normal": NAVY, "hover": CLOSE_HOVER, "pressed": CLOSE_PRESSED}[state] + fg = "#ffffff" if state != "normal" else TB_CLOSE + else: + bg = {"normal": NAVY, "hover": TB_HOVER, "pressed": TB_HOVER_DOWN}[state] + fg = {"normal": TB_MIN, "hover": TB_MIN_HOVER, "pressed": "#ffffff"}[state] + d.rectangle(box(p, 0, 0, 44, 40), fill=bg) + if kind == "min": + d.line([(p(15), p(20)), (p(29), p(20))], fill=fg, width=p(2)) + else: + d.line([(p(16), p(14)), (p(28), p(26))], fill=fg, width=p(2)) + d.line([(p(28), p(14)), (p(16), p(26))], fill=fg, width=p(2)) + return down(im) + + +# ---------------------------------------------------------------- 控件 + +def draw_check(k, on): + p = U(k) + im = Image.new("RGB", (p(16), p(16)), CONTENT_BG) + d = ImageDraw.Draw(im) + if on: + d.rounded_rectangle(box(p, 0, 0, 16, 16), radius=p(4), fill=PRIMARY) + check(d, p, [(4.5, 8.5), (7, 11), (11.5, 5.5)], "#ffffff", 2) + else: + d.rounded_rectangle(box(p, 0, 0, 16, 16), radius=p(4), fill="#ffffff", + outline=BORDER, width=p(1)) + return down(im) + + +def draw_radio(k, on): + p = U(k) + im = Image.new("RGB", (p(16), p(16)), CONTENT_BG) + d = ImageDraw.Draw(im) + if on: + d.ellipse(box(p, 0, 0, 16, 16), fill="#ffffff", outline=PRIMARY, width=p(2)) + d.ellipse(box(p, 5, 5, 6, 6), fill=PRIMARY) + else: + d.ellipse(box(p, 0, 0, 16, 16), fill="#ffffff", outline=BORDER, width=p(1)) + return down(im) + + +def draw_progress(k, step): + """进度条 0..5 档,20% 一格。 + + 进度条底与凹槽位图尺寸完全一致,NSIS 直接叠在凹槽上即可;填充部分左端保留圆角、 + 右端切齐,符合进度条「推进」的视觉。 + """ + p = U(k) + im = Image.new("RGB", (p(456), p(8)), CONTENT_BG) + d = ImageDraw.Draw(im) + d.rounded_rectangle(box(p, 0, 0, 456, 8), radius=p(4), fill=DIVIDER) + frac = step / 5 + if frac > 0: + # 用整体圆角矩形做蒙版,再按进度裁掉右半边,这样左端圆角自然、右端是直边 + mask = Image.new("L", (p(456), p(8)), 0) + ImageDraw.Draw(mask).rounded_rectangle(box(p, 0, 0, 456, 8), radius=p(4), fill=255) + fill_im = Image.new("RGB", (p(456), p(8)), PRIMARY) + cut = max(1, int(round(p(456) * frac))) + region = (0, 0, cut, p(8)) + im.paste(fill_im.crop(region), (0, 0), mask.crop(region)) + return down(im) + + +def draw_stage(k, text): + """阶段文案位图,贴到进度页 (32,116):进度条上方一行,文案换行=状态切换。""" + p = U(k) + im = Image.new("RGB", (p(456), p(18)), CONTENT_BG) + d = ImageDraw.Draw(im) + f = font("Regular", 12.5, k) + b = d.textbbox((0, 0), text, font=f, anchor="la") + d.text((0, (p(18) - (b[3] - b[1])) / 2 - b[1]), text, font=f, fill=TXT_DIM, anchor="la") + return down(im) + + +# ---------------------------------------------------------------- 汇总 + +def all_art(s): + """逐张产出某个倍率的全部素材。 + + 为什么用生成器:k=8 时单张底图就有 70MB,一次性把几十张都留在内存里没必要。 + """ + k = s * SS + yield "base.bmp", draw_base(k) + yield "brand.bmp", draw_brand(k) + yield "titlebar.bmp", draw_titlebar(k) + + yield "page-welcome.bmp", draw_page_welcome(k) + yield "page-license.bmp", draw_page_license(k) + yield "page-dir.bmp", draw_page_dir(k) + yield "page-progress.bmp", draw_page_progress(k) + yield "page-finish.bmp", draw_page_finish(k) + + for label, name in (("下一步", "next"), ("安装", "install"), ("完成", "finish")): + for st in ("normal", "hover", "pressed"): + yield "btn-%s-%s.bmp" % (name, st), draw_button(k, label, BTN_W, BTN_H, "primary", st) + for label, name in (("取消", "cancel"), ("上一步", "back")): + for st in ("normal", "hover", "pressed"): + yield "btn-%s-%s.bmp" % (name, st), draw_button(k, label, BTN_W, BTN_H, "secondary", st) + for st in ("normal", "hover", "pressed"): + yield "btn-browse-%s.bmp" % st, draw_button(k, "浏览…", 96, 34, "secondary", st) + + for name in ("min", "close"): + for st in ("normal", "hover", "pressed"): + yield "tb-%s-%s.bmp" % (name, st), draw_tb_button(k, name, st) + + for st in ("off", "on"): + yield "check-%s.bmp" % st, draw_check(k, st == "on") + yield "radio-%s.bmp" % st, draw_radio(k, st == "on") + + for i in range(6): + yield "progress-%d.bmp" % i, draw_progress(k, i) + + for i, t in enumerate(["正在解压程序文件…", "正在写入注册表…", "正在创建快捷方式…", + "正在放行防火墙…", "正在收尾…"], start=1): + yield "stage-%d.bmp" % i, draw_stage(k, t) + + +# ---------------------------------------------------------------- 联络图 + +def compose(art, page, overlays): + """按真实层级拼出 760×480 的成品观感图:底图 → 品牌栏 → 页面 → 标题栏 → 按钮/控件。""" + im = Image.new("RGB", (WIN_W, WIN_H), CONTENT_BG) + im.paste(art["base.bmp"], (0, 0)) + im.paste(art["brand.bmp"], (0, TITLE_H)) + im.paste(art["page-%s.bmp" % page], (PAGE_X, PAGE_Y)) + im.paste(art["titlebar.bmp"], (0, 0)) + for name, (x, y) in overlays: + im.paste(art[name], (x, y)) + return im + + +def build_contact_sheet(art): + """五页各拼一张成品图,纵向排成长图给人眼过一遍。 + + 单看素材文件看不出层级和对位问题(例如标题栏图形与按钮位图错位、按钮压住正文), + 所以验收必须看合成结果,而不是逐个位图看。 + """ + # 标题栏按钮和底部按钮条按窗口坐标贴,页面内的控件按「页内坐标 + 页面原点」贴, + # 两类坐标混用很容易少加一次偏移,所以这里用 loc() 明确区分 + def loc(x, y): + return x + PAGE_X, y + PAGE_Y + + tb = [("tb-min-normal.bmp", (TB_MIN_BOX[0], TB_MIN_BOX[1])), + ("tb-close-normal.bmp", (TB_CLOSE_BOX[0], TB_CLOSE_BOX[1]))] + cancel = ("btn-cancel-normal.bmp", (BTN_SECONDARY_X, BTN_Y)) + back = ("btn-back-normal.bmp", (BTN_SECONDARY_X, BTN_Y)) + next_ = ("btn-next-normal.bmp", (BTN_PRIMARY_X, BTN_Y)) + + pages = [ + compose(art, "welcome", tb + [cancel, next_]), + compose(art, "license", tb + [cancel, next_]), + compose(art, "dir", tb + [ + ("btn-browse-normal.bmp", loc(392, 110)), + ("radio-off.bmp", loc(32, 220)), ("radio-off.bmp", loc(32, 260)), + back, next_, + ]), + # 进度页取中间一档(第 3 段 / 60%),静态图里最能说明「安装中」的样子 + compose(art, "progress", tb + [ + ("stage-3.bmp", loc(PAD_L, 116)), + ("progress-3.bmp", loc(PAD_L, 140)), + cancel, + ]), + compose(art, "finish", tb + [ + ("check-on.bmp", loc(32, 298)), + ("btn-finish-normal.bmp", (BTN_PRIMARY_X, BTN_Y)), + ]), + ] + + gap = 16 + sheet = Image.new("RGB", (WIN_W + gap * 2, gap + len(pages) * (WIN_H + gap)), "#202a38") + for i, page in enumerate(pages): + sheet.paste(page, (gap, gap + i * (WIN_H + gap))) + os.makedirs(os.path.dirname(CONTACT_SHEET), exist_ok=True) + sheet.save(CONTACT_SHEET) + print("写出 %s %s" % (CONTACT_SHEET, sheet.size)) + + +def write_version_marker(): + """把烘焙进位图的版本号另写一份纯文本旁证。 + + 为什么需要这个文件:版本号是画在 brand.bmp 像素里的,构建脚本没法从位图反读出 + 版本,因此「素材是旧版本生成的、但打包用的是新版本号」这种错配完全看不出来, + 会悄悄发出一个界面上写着错版本号的安装包。这里把 repo_version() 的结果落到 + art/version.txt,构建脚本拿它和仓库根 VERSION 一比,对不上就让构建失败。 + """ + path = os.path.join(ART_DIR, "version.txt") + with open(path, "w", encoding="utf-8", newline="\n") as fh: + fh.write(repo_version() + "\n") + print("写出 %s" % path) + + +def main(): + art1x = {} + for s in (1, 2): + out = os.path.join(ART_DIR, "%dx" % s) + os.makedirs(out, exist_ok=True) + n = 0 + for name, im in all_art(s): + im.save(os.path.join(out, name)) + n += 1 + if s == 1: + art1x[name] = im # 联络图直接用 1x 成品,不要把 2x 缩一半再用(字会发虚) + print("%s:%d 张" % (out, n)) + write_version_marker() + build_contact_sheet(art1x) + + +if __name__ == "__main__": + main() diff --git a/console/installer/art/1x/base.bmp b/console/installer/art/1x/base.bmp new file mode 100644 index 0000000..868b282 Binary files /dev/null and b/console/installer/art/1x/base.bmp differ diff --git a/console/installer/art/1x/brand.bmp b/console/installer/art/1x/brand.bmp new file mode 100644 index 0000000..0520ea9 Binary files /dev/null and b/console/installer/art/1x/brand.bmp differ diff --git a/console/installer/art/1x/btn-back-hover.bmp b/console/installer/art/1x/btn-back-hover.bmp new file mode 100644 index 0000000..d69ead4 Binary files /dev/null and b/console/installer/art/1x/btn-back-hover.bmp differ diff --git a/console/installer/art/1x/btn-back-normal.bmp b/console/installer/art/1x/btn-back-normal.bmp new file mode 100644 index 0000000..93aaba4 Binary files /dev/null and b/console/installer/art/1x/btn-back-normal.bmp differ diff --git a/console/installer/art/1x/btn-back-pressed.bmp b/console/installer/art/1x/btn-back-pressed.bmp new file mode 100644 index 0000000..ca4ce0d Binary files /dev/null and b/console/installer/art/1x/btn-back-pressed.bmp differ diff --git a/console/installer/art/1x/btn-browse-hover.bmp b/console/installer/art/1x/btn-browse-hover.bmp new file mode 100644 index 0000000..88e885d Binary files /dev/null and b/console/installer/art/1x/btn-browse-hover.bmp differ diff --git a/console/installer/art/1x/btn-browse-normal.bmp b/console/installer/art/1x/btn-browse-normal.bmp new file mode 100644 index 0000000..578bcc5 Binary files /dev/null and b/console/installer/art/1x/btn-browse-normal.bmp differ diff --git a/console/installer/art/1x/btn-browse-pressed.bmp b/console/installer/art/1x/btn-browse-pressed.bmp new file mode 100644 index 0000000..a8f2830 Binary files /dev/null and b/console/installer/art/1x/btn-browse-pressed.bmp differ diff --git a/console/installer/art/1x/btn-cancel-hover.bmp b/console/installer/art/1x/btn-cancel-hover.bmp new file mode 100644 index 0000000..0689090 Binary files /dev/null and b/console/installer/art/1x/btn-cancel-hover.bmp differ diff --git a/console/installer/art/1x/btn-cancel-normal.bmp b/console/installer/art/1x/btn-cancel-normal.bmp new file mode 100644 index 0000000..8ecd088 Binary files /dev/null and b/console/installer/art/1x/btn-cancel-normal.bmp differ diff --git a/console/installer/art/1x/btn-cancel-pressed.bmp b/console/installer/art/1x/btn-cancel-pressed.bmp new file mode 100644 index 0000000..c41dec9 Binary files /dev/null and b/console/installer/art/1x/btn-cancel-pressed.bmp differ diff --git a/console/installer/art/1x/btn-finish-hover.bmp b/console/installer/art/1x/btn-finish-hover.bmp new file mode 100644 index 0000000..6d452c4 Binary files /dev/null and b/console/installer/art/1x/btn-finish-hover.bmp differ diff --git a/console/installer/art/1x/btn-finish-normal.bmp b/console/installer/art/1x/btn-finish-normal.bmp new file mode 100644 index 0000000..2f8d5d4 Binary files /dev/null and b/console/installer/art/1x/btn-finish-normal.bmp differ diff --git a/console/installer/art/1x/btn-finish-pressed.bmp b/console/installer/art/1x/btn-finish-pressed.bmp new file mode 100644 index 0000000..8c511d3 Binary files /dev/null and b/console/installer/art/1x/btn-finish-pressed.bmp differ diff --git a/console/installer/art/1x/btn-install-hover.bmp b/console/installer/art/1x/btn-install-hover.bmp new file mode 100644 index 0000000..b0956b4 Binary files /dev/null and b/console/installer/art/1x/btn-install-hover.bmp differ diff --git a/console/installer/art/1x/btn-install-normal.bmp b/console/installer/art/1x/btn-install-normal.bmp new file mode 100644 index 0000000..c4c0fc4 Binary files /dev/null and b/console/installer/art/1x/btn-install-normal.bmp differ diff --git a/console/installer/art/1x/btn-install-pressed.bmp b/console/installer/art/1x/btn-install-pressed.bmp new file mode 100644 index 0000000..8014a8b Binary files /dev/null and b/console/installer/art/1x/btn-install-pressed.bmp differ diff --git a/console/installer/art/1x/btn-next-hover.bmp b/console/installer/art/1x/btn-next-hover.bmp new file mode 100644 index 0000000..a2b202c Binary files /dev/null and b/console/installer/art/1x/btn-next-hover.bmp differ diff --git a/console/installer/art/1x/btn-next-normal.bmp b/console/installer/art/1x/btn-next-normal.bmp new file mode 100644 index 0000000..2fd647f Binary files /dev/null and b/console/installer/art/1x/btn-next-normal.bmp differ diff --git a/console/installer/art/1x/btn-next-pressed.bmp b/console/installer/art/1x/btn-next-pressed.bmp new file mode 100644 index 0000000..13e1383 Binary files /dev/null and b/console/installer/art/1x/btn-next-pressed.bmp differ diff --git a/console/installer/art/1x/check-off.bmp b/console/installer/art/1x/check-off.bmp new file mode 100644 index 0000000..994308f Binary files /dev/null and b/console/installer/art/1x/check-off.bmp differ diff --git a/console/installer/art/1x/check-on.bmp b/console/installer/art/1x/check-on.bmp new file mode 100644 index 0000000..3ad53b1 Binary files /dev/null and b/console/installer/art/1x/check-on.bmp differ diff --git a/console/installer/art/1x/page-dir.bmp b/console/installer/art/1x/page-dir.bmp new file mode 100644 index 0000000..f312928 Binary files /dev/null and b/console/installer/art/1x/page-dir.bmp differ diff --git a/console/installer/art/1x/page-finish.bmp b/console/installer/art/1x/page-finish.bmp new file mode 100644 index 0000000..f8253d2 Binary files /dev/null and b/console/installer/art/1x/page-finish.bmp differ diff --git a/console/installer/art/1x/page-license.bmp b/console/installer/art/1x/page-license.bmp new file mode 100644 index 0000000..f7ff993 Binary files /dev/null and b/console/installer/art/1x/page-license.bmp differ diff --git a/console/installer/art/1x/page-progress.bmp b/console/installer/art/1x/page-progress.bmp new file mode 100644 index 0000000..7b7e5cc Binary files /dev/null and b/console/installer/art/1x/page-progress.bmp differ diff --git a/console/installer/art/1x/page-welcome.bmp b/console/installer/art/1x/page-welcome.bmp new file mode 100644 index 0000000..43335be Binary files /dev/null and b/console/installer/art/1x/page-welcome.bmp differ diff --git a/console/installer/art/1x/progress-0.bmp b/console/installer/art/1x/progress-0.bmp new file mode 100644 index 0000000..b8c5f5c Binary files /dev/null and b/console/installer/art/1x/progress-0.bmp differ diff --git a/console/installer/art/1x/progress-1.bmp b/console/installer/art/1x/progress-1.bmp new file mode 100644 index 0000000..1179f9d Binary files /dev/null and b/console/installer/art/1x/progress-1.bmp differ diff --git a/console/installer/art/1x/progress-2.bmp b/console/installer/art/1x/progress-2.bmp new file mode 100644 index 0000000..47ea599 Binary files /dev/null and b/console/installer/art/1x/progress-2.bmp differ diff --git a/console/installer/art/1x/progress-3.bmp b/console/installer/art/1x/progress-3.bmp new file mode 100644 index 0000000..b009bb0 Binary files /dev/null and b/console/installer/art/1x/progress-3.bmp differ diff --git a/console/installer/art/1x/progress-4.bmp b/console/installer/art/1x/progress-4.bmp new file mode 100644 index 0000000..ed5ce9c Binary files /dev/null and b/console/installer/art/1x/progress-4.bmp differ diff --git a/console/installer/art/1x/progress-5.bmp b/console/installer/art/1x/progress-5.bmp new file mode 100644 index 0000000..61eb44c Binary files /dev/null and b/console/installer/art/1x/progress-5.bmp differ diff --git a/console/installer/art/1x/radio-off.bmp b/console/installer/art/1x/radio-off.bmp new file mode 100644 index 0000000..7e3558d Binary files /dev/null and b/console/installer/art/1x/radio-off.bmp differ diff --git a/console/installer/art/1x/radio-on.bmp b/console/installer/art/1x/radio-on.bmp new file mode 100644 index 0000000..41e7d77 Binary files /dev/null and b/console/installer/art/1x/radio-on.bmp differ diff --git a/console/installer/art/1x/stage-1.bmp b/console/installer/art/1x/stage-1.bmp new file mode 100644 index 0000000..cd1fb04 Binary files /dev/null and b/console/installer/art/1x/stage-1.bmp differ diff --git a/console/installer/art/1x/stage-2.bmp b/console/installer/art/1x/stage-2.bmp new file mode 100644 index 0000000..8ba11cc Binary files /dev/null and b/console/installer/art/1x/stage-2.bmp differ diff --git a/console/installer/art/1x/stage-3.bmp b/console/installer/art/1x/stage-3.bmp new file mode 100644 index 0000000..cae71a6 Binary files /dev/null and b/console/installer/art/1x/stage-3.bmp differ diff --git a/console/installer/art/1x/stage-4.bmp b/console/installer/art/1x/stage-4.bmp new file mode 100644 index 0000000..7bf25d6 Binary files /dev/null and b/console/installer/art/1x/stage-4.bmp differ diff --git a/console/installer/art/1x/stage-5.bmp b/console/installer/art/1x/stage-5.bmp new file mode 100644 index 0000000..9f61848 Binary files /dev/null and b/console/installer/art/1x/stage-5.bmp differ diff --git a/console/installer/art/1x/tb-close-hover.bmp b/console/installer/art/1x/tb-close-hover.bmp new file mode 100644 index 0000000..a3ad967 Binary files /dev/null and b/console/installer/art/1x/tb-close-hover.bmp differ diff --git a/console/installer/art/1x/tb-close-normal.bmp b/console/installer/art/1x/tb-close-normal.bmp new file mode 100644 index 0000000..e1d2722 Binary files /dev/null and b/console/installer/art/1x/tb-close-normal.bmp differ diff --git a/console/installer/art/1x/tb-close-pressed.bmp b/console/installer/art/1x/tb-close-pressed.bmp new file mode 100644 index 0000000..c09d452 Binary files /dev/null and b/console/installer/art/1x/tb-close-pressed.bmp differ diff --git a/console/installer/art/1x/tb-min-hover.bmp b/console/installer/art/1x/tb-min-hover.bmp new file mode 100644 index 0000000..efd79ba Binary files /dev/null and b/console/installer/art/1x/tb-min-hover.bmp differ diff --git a/console/installer/art/1x/tb-min-normal.bmp b/console/installer/art/1x/tb-min-normal.bmp new file mode 100644 index 0000000..352912c Binary files /dev/null and b/console/installer/art/1x/tb-min-normal.bmp differ diff --git a/console/installer/art/1x/tb-min-pressed.bmp b/console/installer/art/1x/tb-min-pressed.bmp new file mode 100644 index 0000000..39aa7f8 Binary files /dev/null and b/console/installer/art/1x/tb-min-pressed.bmp differ diff --git a/console/installer/art/1x/titlebar.bmp b/console/installer/art/1x/titlebar.bmp new file mode 100644 index 0000000..f08a5e4 Binary files /dev/null and b/console/installer/art/1x/titlebar.bmp differ diff --git a/console/installer/art/2x/base.bmp b/console/installer/art/2x/base.bmp new file mode 100644 index 0000000..16d9d80 Binary files /dev/null and b/console/installer/art/2x/base.bmp differ diff --git a/console/installer/art/2x/brand.bmp b/console/installer/art/2x/brand.bmp new file mode 100644 index 0000000..8592a92 Binary files /dev/null and b/console/installer/art/2x/brand.bmp differ diff --git a/console/installer/art/2x/btn-back-hover.bmp b/console/installer/art/2x/btn-back-hover.bmp new file mode 100644 index 0000000..c858d0f Binary files /dev/null and b/console/installer/art/2x/btn-back-hover.bmp differ diff --git a/console/installer/art/2x/btn-back-normal.bmp b/console/installer/art/2x/btn-back-normal.bmp new file mode 100644 index 0000000..d041133 Binary files /dev/null and b/console/installer/art/2x/btn-back-normal.bmp differ diff --git a/console/installer/art/2x/btn-back-pressed.bmp b/console/installer/art/2x/btn-back-pressed.bmp new file mode 100644 index 0000000..914a343 Binary files /dev/null and b/console/installer/art/2x/btn-back-pressed.bmp differ diff --git a/console/installer/art/2x/btn-browse-hover.bmp b/console/installer/art/2x/btn-browse-hover.bmp new file mode 100644 index 0000000..8da86b1 Binary files /dev/null and b/console/installer/art/2x/btn-browse-hover.bmp differ diff --git a/console/installer/art/2x/btn-browse-normal.bmp b/console/installer/art/2x/btn-browse-normal.bmp new file mode 100644 index 0000000..7e4d775 Binary files /dev/null and b/console/installer/art/2x/btn-browse-normal.bmp differ diff --git a/console/installer/art/2x/btn-browse-pressed.bmp b/console/installer/art/2x/btn-browse-pressed.bmp new file mode 100644 index 0000000..c62d17c Binary files /dev/null and b/console/installer/art/2x/btn-browse-pressed.bmp differ diff --git a/console/installer/art/2x/btn-cancel-hover.bmp b/console/installer/art/2x/btn-cancel-hover.bmp new file mode 100644 index 0000000..04c777c Binary files /dev/null and b/console/installer/art/2x/btn-cancel-hover.bmp differ diff --git a/console/installer/art/2x/btn-cancel-normal.bmp b/console/installer/art/2x/btn-cancel-normal.bmp new file mode 100644 index 0000000..efa6976 Binary files /dev/null and b/console/installer/art/2x/btn-cancel-normal.bmp differ diff --git a/console/installer/art/2x/btn-cancel-pressed.bmp b/console/installer/art/2x/btn-cancel-pressed.bmp new file mode 100644 index 0000000..944d74f Binary files /dev/null and b/console/installer/art/2x/btn-cancel-pressed.bmp differ diff --git a/console/installer/art/2x/btn-finish-hover.bmp b/console/installer/art/2x/btn-finish-hover.bmp new file mode 100644 index 0000000..e641c60 Binary files /dev/null and b/console/installer/art/2x/btn-finish-hover.bmp differ diff --git a/console/installer/art/2x/btn-finish-normal.bmp b/console/installer/art/2x/btn-finish-normal.bmp new file mode 100644 index 0000000..a7260a8 Binary files /dev/null and b/console/installer/art/2x/btn-finish-normal.bmp differ diff --git a/console/installer/art/2x/btn-finish-pressed.bmp b/console/installer/art/2x/btn-finish-pressed.bmp new file mode 100644 index 0000000..bd24e5b Binary files /dev/null and b/console/installer/art/2x/btn-finish-pressed.bmp differ diff --git a/console/installer/art/2x/btn-install-hover.bmp b/console/installer/art/2x/btn-install-hover.bmp new file mode 100644 index 0000000..68609f6 Binary files /dev/null and b/console/installer/art/2x/btn-install-hover.bmp differ diff --git a/console/installer/art/2x/btn-install-normal.bmp b/console/installer/art/2x/btn-install-normal.bmp new file mode 100644 index 0000000..dafe63a Binary files /dev/null and b/console/installer/art/2x/btn-install-normal.bmp differ diff --git a/console/installer/art/2x/btn-install-pressed.bmp b/console/installer/art/2x/btn-install-pressed.bmp new file mode 100644 index 0000000..8e9c4be Binary files /dev/null and b/console/installer/art/2x/btn-install-pressed.bmp differ diff --git a/console/installer/art/2x/btn-next-hover.bmp b/console/installer/art/2x/btn-next-hover.bmp new file mode 100644 index 0000000..e9b5b46 Binary files /dev/null and b/console/installer/art/2x/btn-next-hover.bmp differ diff --git a/console/installer/art/2x/btn-next-normal.bmp b/console/installer/art/2x/btn-next-normal.bmp new file mode 100644 index 0000000..a44211e Binary files /dev/null and b/console/installer/art/2x/btn-next-normal.bmp differ diff --git a/console/installer/art/2x/btn-next-pressed.bmp b/console/installer/art/2x/btn-next-pressed.bmp new file mode 100644 index 0000000..1e2c699 Binary files /dev/null and b/console/installer/art/2x/btn-next-pressed.bmp differ diff --git a/console/installer/art/2x/check-off.bmp b/console/installer/art/2x/check-off.bmp new file mode 100644 index 0000000..faddad1 Binary files /dev/null and b/console/installer/art/2x/check-off.bmp differ diff --git a/console/installer/art/2x/check-on.bmp b/console/installer/art/2x/check-on.bmp new file mode 100644 index 0000000..2683795 Binary files /dev/null and b/console/installer/art/2x/check-on.bmp differ diff --git a/console/installer/art/2x/page-dir.bmp b/console/installer/art/2x/page-dir.bmp new file mode 100644 index 0000000..abf0689 Binary files /dev/null and b/console/installer/art/2x/page-dir.bmp differ diff --git a/console/installer/art/2x/page-finish.bmp b/console/installer/art/2x/page-finish.bmp new file mode 100644 index 0000000..21afc2d Binary files /dev/null and b/console/installer/art/2x/page-finish.bmp differ diff --git a/console/installer/art/2x/page-license.bmp b/console/installer/art/2x/page-license.bmp new file mode 100644 index 0000000..85e5433 Binary files /dev/null and b/console/installer/art/2x/page-license.bmp differ diff --git a/console/installer/art/2x/page-progress.bmp b/console/installer/art/2x/page-progress.bmp new file mode 100644 index 0000000..a36b409 Binary files /dev/null and b/console/installer/art/2x/page-progress.bmp differ diff --git a/console/installer/art/2x/page-welcome.bmp b/console/installer/art/2x/page-welcome.bmp new file mode 100644 index 0000000..8301b0d Binary files /dev/null and b/console/installer/art/2x/page-welcome.bmp differ diff --git a/console/installer/art/2x/progress-0.bmp b/console/installer/art/2x/progress-0.bmp new file mode 100644 index 0000000..6f3f9d8 Binary files /dev/null and b/console/installer/art/2x/progress-0.bmp differ diff --git a/console/installer/art/2x/progress-1.bmp b/console/installer/art/2x/progress-1.bmp new file mode 100644 index 0000000..75dda3e Binary files /dev/null and b/console/installer/art/2x/progress-1.bmp differ diff --git a/console/installer/art/2x/progress-2.bmp b/console/installer/art/2x/progress-2.bmp new file mode 100644 index 0000000..a286cb2 Binary files /dev/null and b/console/installer/art/2x/progress-2.bmp differ diff --git a/console/installer/art/2x/progress-3.bmp b/console/installer/art/2x/progress-3.bmp new file mode 100644 index 0000000..7124096 Binary files /dev/null and b/console/installer/art/2x/progress-3.bmp differ diff --git a/console/installer/art/2x/progress-4.bmp b/console/installer/art/2x/progress-4.bmp new file mode 100644 index 0000000..99fb498 Binary files /dev/null and b/console/installer/art/2x/progress-4.bmp differ diff --git a/console/installer/art/2x/progress-5.bmp b/console/installer/art/2x/progress-5.bmp new file mode 100644 index 0000000..a1002d7 Binary files /dev/null and b/console/installer/art/2x/progress-5.bmp differ diff --git a/console/installer/art/2x/radio-off.bmp b/console/installer/art/2x/radio-off.bmp new file mode 100644 index 0000000..5e7990f Binary files /dev/null and b/console/installer/art/2x/radio-off.bmp differ diff --git a/console/installer/art/2x/radio-on.bmp b/console/installer/art/2x/radio-on.bmp new file mode 100644 index 0000000..2688507 Binary files /dev/null and b/console/installer/art/2x/radio-on.bmp differ diff --git a/console/installer/art/2x/stage-1.bmp b/console/installer/art/2x/stage-1.bmp new file mode 100644 index 0000000..801be65 Binary files /dev/null and b/console/installer/art/2x/stage-1.bmp differ diff --git a/console/installer/art/2x/stage-2.bmp b/console/installer/art/2x/stage-2.bmp new file mode 100644 index 0000000..8d11bc2 Binary files /dev/null and b/console/installer/art/2x/stage-2.bmp differ diff --git a/console/installer/art/2x/stage-3.bmp b/console/installer/art/2x/stage-3.bmp new file mode 100644 index 0000000..3211e83 Binary files /dev/null and b/console/installer/art/2x/stage-3.bmp differ diff --git a/console/installer/art/2x/stage-4.bmp b/console/installer/art/2x/stage-4.bmp new file mode 100644 index 0000000..2c64c49 Binary files /dev/null and b/console/installer/art/2x/stage-4.bmp differ diff --git a/console/installer/art/2x/stage-5.bmp b/console/installer/art/2x/stage-5.bmp new file mode 100644 index 0000000..3cb58b9 Binary files /dev/null and b/console/installer/art/2x/stage-5.bmp differ diff --git a/console/installer/art/2x/tb-close-hover.bmp b/console/installer/art/2x/tb-close-hover.bmp new file mode 100644 index 0000000..3c668c6 Binary files /dev/null and b/console/installer/art/2x/tb-close-hover.bmp differ diff --git a/console/installer/art/2x/tb-close-normal.bmp b/console/installer/art/2x/tb-close-normal.bmp new file mode 100644 index 0000000..f27517c Binary files /dev/null and b/console/installer/art/2x/tb-close-normal.bmp differ diff --git a/console/installer/art/2x/tb-close-pressed.bmp b/console/installer/art/2x/tb-close-pressed.bmp new file mode 100644 index 0000000..425ea9c Binary files /dev/null and b/console/installer/art/2x/tb-close-pressed.bmp differ diff --git a/console/installer/art/2x/tb-min-hover.bmp b/console/installer/art/2x/tb-min-hover.bmp new file mode 100644 index 0000000..2a93df2 Binary files /dev/null and b/console/installer/art/2x/tb-min-hover.bmp differ diff --git a/console/installer/art/2x/tb-min-normal.bmp b/console/installer/art/2x/tb-min-normal.bmp new file mode 100644 index 0000000..da447dd Binary files /dev/null and b/console/installer/art/2x/tb-min-normal.bmp differ diff --git a/console/installer/art/2x/tb-min-pressed.bmp b/console/installer/art/2x/tb-min-pressed.bmp new file mode 100644 index 0000000..548fb71 Binary files /dev/null and b/console/installer/art/2x/tb-min-pressed.bmp differ diff --git a/console/installer/art/2x/titlebar.bmp b/console/installer/art/2x/titlebar.bmp new file mode 100644 index 0000000..1d33b1a Binary files /dev/null and b/console/installer/art/2x/titlebar.bmp differ diff --git a/console/installer/art/version.txt b/console/installer/art/version.txt new file mode 100644 index 0000000..4b9fcbe --- /dev/null +++ b/console/installer/art/version.txt @@ -0,0 +1 @@ +0.5.1 diff --git a/console/installer/installer.nsi b/console/installer/installer.nsi new file mode 100644 index 0000000..571976c --- /dev/null +++ b/console/installer/installer.nsi @@ -0,0 +1,1375 @@ +; ============================================================================= +; 赛事积分榜 安装器 —— 完全自绘的五页向导 +; +; 这是 electron-builder 的「自定义 installer 脚本」(package.json 的 build.nsis.script)。 +; 只换 UI 层:安装/卸载/升级/快捷方式/注册表/防火墙全部复用 electron-builder 模板里的宏, +; 一个字节的安装逻辑都不重写 —— 现场装机成功率不被这次改版动摇。 +; +; 为什么整窗去边框、铺满位图: +; 设计要求是 760×480 的固定版面(左侧品牌栏 / 顶部标题栏 / 右侧内容区 / 底部按钮条)。 +; 系统标题栏和原生按钮条在不同 Windows 版本上高度都不一样,要对齐只能自己画。 +; 所以:去掉窗口边框 → 内层对话框铺满整个客户区 → 位图当画布 → 标准按钮条被盖住, +; 但仍然是「程序化翻页」的工具(点我们的按钮 = 点被盖住的系统按钮)。 +; +; 页面流程:P1 欢迎 → P2 许可协议 → P3 安装位置与安装模式 → P4 正在安装 → P5 完成。 +; P1/P2/P3/P5 是 Page custom(nsDialogs 自建内层对话框,POC 已验证); +; P4 必须是**真正的** instfiles 页,原因有两个,都不是偏好问题: +; 1. 安装段(Section)只在 instfiles 页上执行。不用 instfiles 页就得把整段安装逻辑塞进 +; 一个函数,静默安装、取消中断、进度刷新全得自己重写 —— 那才是真正的风险源; +; 2. WriteUninstaller / 卸载段的执行时机也依赖这条路径。 +; 代价是 instfiles 页自带的内层对话框(原生进度条 + 详情列表)只能藏起来, +; 在上面再盖一层我们自己的对话框。 +; +; 高 DPI(现场是 4K@200%): +; ManifestDPIAware true + 运行时读 LOGPIXELSX,所有坐标 = 1x 设计值 × dpi / 96, +; 一个绝对像素值都不许写死在控件上;素材出 1x/2x 两套,dpi ≥ 144 用 2x, +; 并且给 LoadImage 传目标 cx/cy,让系统在 125%/150% 这类中间档位上兜底缩放。 +; 字号不加倍:NSIS 的 CreateFont 自己会按系统 DPI 把点数换成像素,再乘一次就双倍了。 +; ============================================================================= + +; ── 素材位置 ──────────────────────────────────────────────────────────────── +; 走 ${PROJECT_DIR}(electron-builder 注入的绝对路径)而不是相对路径: +; electron-builder 是把脚本喂给 makensis 的 stdin、并把 makensis 的工作目录设成它的模板目录, +; 相对路径落点不确定。 +!define ART_SRC "${PROJECT_DIR}\installer\art" + +; ── 版面常量 ──────────────────────────────────────────────────────────────── +; 全部是 art-gen/gen_art.py 里的 1x 设计值,改素材就要回来改这里(两边必须一致)。 +!define WIN_W 760 +!define WIN_H 480 +!define TB_H 40 +!define SIDE_W 240 +!define BRAND_H 440 +!define PG_X 240 +!define PG_Y 40 +!define PG_W 520 +!define PG_H 440 +!define PAD_L 32 +!define CONTENT_W 456 +!define BTN_W 108 +!define BTN_H 36 +!define BTN_Y 416 +!define BTN_PRIMARY_X 620 +!define BTN_SECONDARY_X 500 +!define BROWSE_W 96 +!define BROWSE_H 34 +!define TB_MIN_X 672 +!define TB_CLOSE_X 716 +!define TB_BTN_W 44 +; 页面内控件 → 窗口坐标 = 页内坐标 + (PG_X, PG_Y),这里直接写成窗口坐标 +!define LIC_EDIT_X 273 +!define LIC_EDIT_Y 237 +!define LIC_EDIT_H 174 +!define LIC_EDIT_W 454 +!define LIC_CHK_X 272 +!define LIC_CHK_Y 432 +!define PATH_EDIT_X 273 +!define PATH_EDIT_Y 151 +!define PATH_EDIT_W 357 +!define PATH_EDIT_H 32 +!define BROWSE_X 632 +!define BROWSE_Y 150 +!define RADIO1_X 272 +!define RADIO1_Y 260 +!define RADIO2_X 272 +!define RADIO2_Y 300 +!define SIZE_TXT_Y 336 +!define STAGE_X 272 +!define STAGE_Y 156 +!define STAGE_W 456 +!define STAGE_H 18 +!define PROG_X 272 +!define PROG_Y 180 +!define PROG_W 456 +!define PROG_H 8 +!define FIN_CHK_X 272 +!define FIN_CHK_Y 338 +!define FIN_PATH_Y 376 + +; 颜色取自 gen_art.py 的配色常量(SetCtlColors 收 RRGGBB,不是 BGR) +!define CLR_TXT "0x16233a" +!define CLR_DIM "0x5a6b85" +!define CLR_BG "0xf6f9fd" + +; ── 变量 ─────────────────────────────────────────────────────────────────── +; electron-builder 的模板(assistedInstaller / installSection)引用了这些变量, +; 平时由 installer.nsi 主模板声明;我们用自定义脚本,得自己声明,名字必须一致。 +Var newStartMenuLink +Var oldStartMenuLink +Var newDesktopLink +Var oldDesktopLink +Var oldShortcutName +Var oldMenuDirectory +Var appExe +Var launchLink + +; 自绘界面状态 +Var hPage ; 当前页的内层对话框 +Var hBg +Var hBrand +Var hTitleBar +Var hMin +Var hClose +Var hNext +Var hBack +Var hCancel +Var hBrowse +Var hFinish +Var hCheck +Var hRadio1 +Var hRadio2 +Var hPath +Var hLicEdit +Var hStage +Var hProg +Var stMin +Var stClose +Var stNext +Var stBack +Var stCancel +Var stBrowse +Var stFinish +Var uiPage ; 0=无页面,1..5=当前页;定时器靠它判断还能碰哪些控件 +Var timerId +Var gDpi ; 系统 DPI(LOGPIXELSX) +Var gArt ; 本次要用的素材目录($PLUGINSDIR\1x 或 \2x) +Var gCx +Var gCy +Var gBw ; Track 宏把基准宽高放这里,转交给 SetBmp +Var gBh +Var tmpName +Var tmpPath +Var tmpTxt +Var /GLOBAL IsPowerShellAvailable +Var artReady +Var licOk ; 许可页勾选 +Var runApp ; 完成页「立即运行」 +Var modeAll ; 安装模式单选:1=为所有用户,0=仅为我 +Var instStarted ; 安装是否已经开工(Tick 里触发,只触发一次) +Var keepShortcuts ; 快捷方式保留策略,由安装流程写定(上游在安装段里声明,我们提前到全局) +Var dragging + + +; ── include ──────────────────────────────────────────────────────────────── +; MUI2 不是为了它的页面,是为了 LangString / MUI_LANGUAGE: +; electron-builder 注入的 messages.nsh(以及 assistedMessages.nsh)用的是 MUI 的 LangString, +; 没有 MUI2 直接编译不过。我们的页面全是 Page custom,一个 MUI_PAGE_* 都不插。 +!define MUI_DISABLE_INSERT_LANGUAGE_AFTER_PAGES_WARNING +!include "MUI2.nsh" +!include "nsDialogs.nsh" +!include "WinMessages.nsh" +!include "LogicLib.nsh" +!include "FileFunc.nsh" +!include "StrContains.nsh" +!include "common.nsh" +!include "multiUser.nsh" +!include "allowOnlyOneInstallerInstance.nsh" +!include "installUtil.nsh" +!include "installer.nsh" + +; 安装模式初值逻辑(照抄 templates/nsis/assistedInstaller.nsh 的 initMultiUser): +; 上次怎么装的还怎么装;命令行 --allusers / --currentuser 压过它; +; 都没有时看 INSTALL_MODE_PER_ALL_USERS_DEFAULT(= package.json 的 selectPerMachineByDefault)。 +; 我们不 include assistedInstaller.nsh(它会插一堆 MUI 原生页面),所以这个宏得自己带一份, +; 否则 uninstaller.nsh 的 un.onInit 里那句 !insertmacro initMultiUser 直接找不到宏。 +!macro initMultiUser + !ifdef INSTALL_MODE_PER_ALL_USERS + !insertmacro setInstallModePerAllUsers + !else + ; 静默安装跳过「内层进程 / 非管理员」这道判断:它俩走 UAC 插件的 UAC::_, + ; 而静默模式下没有页窗口,插件会在这一句上不返回(本机 wine 实测卡死 60 秒)。 + ; 静默安装要提权时由安装流程开头那段($installMode == "all" + ${Silent})自己处理。 + ${IfNot} ${Silent} + ${If} ${UAC_IsInnerInstance} + ${AndIfNot} ${UAC_IsAdmin} + SetErrorLevel 0x666666 + Quit + ${EndIf} + ${EndIf} + + !ifndef MULTIUSER_INIT_TEXT_ADMINREQUIRED + !define MULTIUSER_INIT_TEXT_ADMINREQUIRED "$(^Caption) requires administrator privileges." + !endif + !ifndef MULTIUSER_INIT_TEXT_POWERREQUIRED + !define MULTIUSER_INIT_TEXT_POWERREQUIRED "$(^Caption) requires at least Power User privileges." + !endif + !ifndef MULTIUSER_INIT_TEXT_ALLUSERSNOTPOSSIBLE + !define MULTIUSER_INIT_TEXT_ALLUSERSNOTPOSSIBLE "Your user account does not have sufficient privileges to install $(^Name) for all users of this computer." + !endif + + StrCpy $hasPerMachineInstallation "0" + StrCpy $hasPerUserInstallation "0" + ReadRegStr $perMachineInstallationFolder HKLM "${INSTALL_REGISTRY_KEY}" InstallLocation + ${if} $perMachineInstallationFolder != "" + StrCpy $hasPerMachineInstallation "1" + ${endif} + ReadRegStr $perUserInstallationFolder HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation + ${if} $perUserInstallationFolder != "" + StrCpy $hasPerUserInstallation "1" + ${endif} + + ${GetParameters} $R0 + ${GetOptions} $R0 "/allusers" $R1 + ${IfNot} ${Errors} + StrCpy $hasPerMachineInstallation "1" + StrCpy $hasPerUserInstallation "0" + ${EndIf} + ${GetOptions} $R0 "/currentuser" $R1 + ${IfNot} ${Errors} + StrCpy $hasPerMachineInstallation "0" + StrCpy $hasPerUserInstallation "1" + ${EndIf} + + ${if} $hasPerUserInstallation == "1" + ${andif} $hasPerMachineInstallation == "0" + !insertmacro setInstallModePerUser + ${elseif} $hasPerUserInstallation == "0" + ${andif} $hasPerMachineInstallation == "1" + !insertmacro setInstallModePerAllUsers + ${else} + !ifdef INSTALL_MODE_PER_ALL_USERS + !insertmacro setInstallModePerAllUsers + !else + !ifdef INSTALL_MODE_PER_ALL_USERS_DEFAULT + !insertmacro setInstallModePerAllUsers + !else + !insertmacro setInstallModePerUser + !endif + !endif + ${endif} + !endif +!macroend + +; ── 编译期替 electron-builder 补的两处 ────────────────────────────────────── +; 1) 自定义脚本下 electron-builder 不做 BUILD_UNINSTALLER 那一趟,UNINSTALLER_OUT_FILE +; 被留成空值,而 include/installer.nsh 里有一条无条件的 +; File "/oname=${UNINSTALL_FILENAME}" "${UNINSTALLER_OUT_FILE}" +; 空路径直接编译失败。真卸载器由安装段末尾的 WriteUninstaller 现写, +; 这里顶一个占位文件上去只为让编译过去(安装时会被立刻删掉再重写,见安装段)。 +!ifdef UNINSTALLER_OUT_FILE + !define /redef UNINSTALLER_OUT_FILE "${PROJECT_DIR}\installer\uninstaller-stub.txt" +!else + !define UNINSTALLER_OUT_FILE "${PROJECT_DIR}\installer\uninstaller-stub.txt" +!endif + +; 2) MUI_INSERT 是在第一个 MUI_LANGUAGE 处展开的,界面初始化钩子必须在那之前定义。 +!define MUI_CUSTOMFUNCTION_GUIINIT "GuiInit" + +; 「应用还在跑吗」的检查。不查的话覆盖安装时 CopyFiles 会一路失败, +; 用户最后只看到一个「无法关闭,重试/取消」的框,根本不知道要先关程序。 +; 逻辑取自 allowOnlyOneInstallerInstance.nsh 的 CHECK_APP_RUNNING,但不能直接插那个宏: +; 1. 它的宏体带 Var /GLOBAL CmdPath / PowerShellPath,一个脚本只能插一次, +; 而那一次已经被 un.checkAppRunning 占掉(我们 include 了 uninstaller.nsh); +; 2. 卸载器侧的 Call 只能指向 un. 开头的函数,安装器又调不了 un. 函数, +; 所以两边各要一个入口,共用下面这一份宏体。 +; 另外 $CmdPath / $PowerShellPath / $IsPowerShellAvailable 都由 un.checkAppRunning +; 那次展开声明(所以本文件里这一整段必须在 !include "uninstaller.nsh" 之后)。 +!macro AppRunningCheck + StrCpy $CmdPath "$SYSDIR\cmd.exe" + StrCpy $PowerShellPath "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + !insertmacro ProbePowerShell + !insertmacro _CHECK_APP_RUNNING +!macroend + +; IS_POWERSHELL_AVAILABLE 的宏体自带 Var /GLOBAL IsPowerShellAvailable, +; 两边各插一次会报「already declared」,所以那句 Var 已经提到文件开头的变量区, +; 探测逻辑原样抄在这里(十几行,抄一份比去改上游模板安全)。 +!macro ProbePowerShell + ; 先用一个必然返回 7 的命令确认这是「真的」PowerShell。 + ; 上游模板直接问 Get-Command Get-CimInstance,但假壳(wine 自带的 powershell.exe 就是, + ; 任何参数都 exit 0)会让这个探测和后面那条 Get-CimInstance 查询一律「成功」, + ; 于是安装器误判「程序正在运行」,去杀一个不存在的进程、卡在重试框里。 + ; 这类假壳在现场也可能出现(安全软件挂的 shell 代理),所以宁可多花一次子进程。 + nsExec::Exec `"$PowerShellPath" -C "exit 7"` + Pop $0 + ${if} $0 != 7 + StrCpy $IsPowerShellAvailable 1 + ${else} + nsExec::Exec `"$PowerShellPath" -C "if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"` + Pop $0 + ${if} $0 == 0 + nsExec::Exec `"$PowerShellPath" -C "if ((Get-ExecutionPolicy -Scope Process) -eq 'Restricted') { exit 1 } else { exit 0 }"` + Pop $0 + ${endIf} + ${if} $0 != 0 + StrCpy $0 1 + ${endIf} + StrCpy $IsPowerShellAvailable $0 + ${endIf} +!macroend + +; ── 卸载段 ───────────────────────────────────────────────────────────────── +; 卸载逻辑与 un.onInit 直接用 electron-builder 的模板(uninstaller.nsh),一个字节都不改: +; 升级、快捷方式回收、防火墙规则回收的行为与上游完全一致。 +; 卸载器的界面仍是原生外观 —— 素材只有安装器那一套、5 张页面位图全带「安装」字样, +; 硬套上去只会更难看。 +; +; ⚠️ 为什么这里要自己给 BUILD_UNINSTALLER: +; electron-builder 平时编两趟(安装器一趟、卸载器一趟),靠 BUILD_UNINSTALLER 这个 +; define 区分两边的代码路径(典型的是 getProcessInfo.nsh:安装器定义 _GetProcessInfo、 +; 卸载器定义 un._GetProcessInfo)。自定义脚本只有一趟编译,安装器和卸载器的代码同处 +; 一个文件里,所以只能给「卸载器这一段」自己划一个作用域: +; · 定义 BUILD_UNINSTALLER → 让 un. 侧拿到 Call un._GetProcessInfo(否则卸载器里的 +; Call 不许指向非 un. 函数,直接编译不过); +; · include 完之后 !undef,安装器侧的代码照旧走 _GetProcessInfo。 +; 顺带这也让 un.onInit 里的 initMultiUser 走到「卸载器需要提权」的那条分支, +; 与上游卸载器编译结果一致。 + +!macro customCheckAppRunning + Call un.CheckAppRunningImpl +!macroend + +!define BUILD_UNINSTALLER +!include "uninstaller.nsh" + +; getProcessInfo.nsh 的 un._GetProcessInfo 只在 BUILD_UNINSTALLER 那一趟里定义; +; 我们的单趟编译里补一个,宏体用的还是上游那份 FUNC_GETPROCESSINFO。 +Function un._GetProcessInfo + !insertmacro FUNC_GETPROCESSINFO +FunctionEnd + +Function un.CheckAppRunningImpl + !insertmacro AppRunningCheck +FunctionEnd +!undef BUILD_UNINSTALLER + +!insertmacro addLangs + + +; ── 通用函数 ─────────────────────────────────────────────────────────────── + +Function CheckAppRunningImpl + !insertmacro AppRunningCheck +FunctionEnd + +; 按 DPI 缩放基准值。栈:基准像素值 → 返回缩放后的像素值。 +; 只用 $R8/$R9:所有调用方都用 $R0..$R7 放自己的临时值,S 不能踩它们。 +Function S + Exch $R9 + Push $R8 + IntOp $R8 $R9 * $gDpi + IntOp $R8 $R8 / 96 + StrCpy $R9 $R8 + Pop $R8 + Exch $R9 +FunctionEnd + +; 贴位图。栈:句柄 文件名 消息 基准宽 基准高。 +; 文件名只有名字(如 base.bmp),目录由 $gArt 决定;cx/cy 用控件的目标像素尺寸, +; 于是 2x 素材在 150% 这类非整数倍下也能由系统缩到刚好。 +Function SetBmp + Pop $R4 ; 文件名 + Pop $R3 ; 句柄 + Pop $R2 ; 消息 + Pop $R1 ; 基准宽 + Pop $R0 ; 基准高 + StrCpy $tmpPath "$gArt\$R4" + Push $R1 + Call S + Pop $R5 ; 目标宽 + Push $R0 + Call S + Pop $R6 ; 目标高 + System::Call 'user32::LoadImageW(p 0, w "$tmpPath", i 0, i $R5, i $R6, i 0x0010) p .R0' + ${If} $R0 == 0 + Return ; 素材没加载出来就保持原图,宁可画面旧一点也不画成空白 + ${EndIf} + System::Call 'user32::SendMessageW(p $R3, i $R2, p 0, p $R0)' + ; 立即重画:安装段执行期间消息泵不一定马上转到,进度不能等到空闲才更新 + System::Call 'user32::InvalidateRect(p $R3, p 0, i 1)' + System::Call 'user32::UpdateWindow(p $R3)' +FunctionEnd + +; 建控件。栈顺序见宏;类名/样式是编译期文本,坐标是 1x 设计值。 +!macro NewCtl CLASS STYLE BX BY BW BH OUT + Push ${BX} + Call S + Pop $R0 + Push ${BY} + Call S + Pop $R1 + Push ${BW} + Call S + Pop $R2 + Push ${BH} + Call S + Pop $R3 + nsDialogs::CreateControl "${CLASS}" "${STYLE}" 0 $R0 $R1 $R2 $R3 "" + Pop ${OUT} +!macroend + +; 贴位图(参数按 1x 设计值写,内部转成像素) +!macro SetBmpBase HWND ONAME W H + Push ${H} + Push ${W} + Push ${BM_SETIMAGE} + Push ${HWND} + Push ${ONAME} + Call SetBmp +!macroend + +!macro SetBmpStatic HWND ONAME W H + Push ${H} + Push ${W} + Push ${STM_SETIMAGE} + Push ${HWND} + Push ${ONAME} + Call SetBmp +!macroend + +; 位图按钮:创建 + 贴常态图 +!macro NewBmpBtn BX BY BW BH OUT ONAME + !insertmacro NewCtl "Button" "${BS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${BX} ${BY} ${BW} ${BH} ${OUT} + !insertmacro SetBmpBase ${OUT} ${ONAME} ${BW} ${BH} +!macroend + +; 悬停跟踪:栈里带齐「基准矩形 + 句柄 + 当前状态 + 位图基名」。 +; 状态机 0=常态 1=悬停 2=按下,和 art/ 的三态位图一一对应。 +!macro Track HWND STATE ONAME BX BY BW BH + StrCpy $gBw ${BW} + StrCpy $gBh ${BH} + Push ${BX} + Push ${BY} + Push ${BW} + Push ${BH} + Push ${HWND} + Push ${STATE} + Push "${ONAME}" + Call HoverBtn + Pop ${STATE} +!macroend + +; 光标位置换算 + 命中测试 → 需要时换位图。栈:基准x 基准y 基准宽 基准高 句柄 状态 位图基名(base)。 +; 用 $R0..$R6 干活、$R7 存结果 —— SetBmp 只踩 $R0..$R6,所以 $R7 能活到返回。 +Function HoverBtn + Pop $R0 ; 位图基名 + Pop $R1 ; 当前状态 + Pop $R2 ; 句柄 + Pop $R3 ; 基准高 + Pop $R4 ; 基准宽 + Pop $R5 ; 基准 y + Pop $R6 ; 基准 x + Push $R6 + Call S + Pop $R6 ; x + Push $R5 + Call S + Pop $R5 ; y + Push $R4 + Call S + Pop $R4 ; w + Push $R3 + Call S + Pop $R3 ; h + ; 右/下边界(命中测试用半开区间) + IntOp $R4 $R4 + $R6 + IntOp $R3 $R3 + $R5 + StrCpy $R7 0 + ${If} $gCx >= $R6 + ${AndIf} $gCx < $R4 + ${AndIf} $gCy >= $R5 + ${AndIf} $gCy < $R3 + StrCpy $R7 1 + System::Call 'user32::GetAsyncKeyState(i 1) i .R8' + IntOp $R8 $R8 & 0x8000 + ${If} $R8 != 0 + StrCpy $R7 2 + ${EndIf} + ${EndIf} + ${If} $R7 != $R1 + ${If} $R7 == 0 + StrCpy $tmpName "$R0-normal.bmp" + ${ElseIf} $R7 == 1 + StrCpy $tmpName "$R0-hover.bmp" + ${Else} + StrCpy $tmpName "$R0-pressed.bmp" + ${EndIf} + Push $gBh + Push $gBw + Push ${BM_SETIMAGE} + Push $R2 + Push $tmpName + Call SetBmp + ${EndIf} + Push $R7 +FunctionEnd + +; 定时器回调:光标 → 客户区坐标 → 逐控件做悬停/按下 → 标题栏拖动。 +; 不做窗口子类化(POC 结论):60ms 轮询足够跟手,也没有子类化回调崩掉整个安装器的风险。 +Function Tick + System::Call '*(i, i) p .R0' + System::Call 'user32::GetCursorPos(p R0)' + System::Call 'user32::ScreenToClient(p $HWNDPARENT, p R0)' + System::Call '*$R0(i .R1, i .R2)' + System::Free $R0 + StrCpy $gCx $R1 + StrCpy $gCy $R2 + + ${If} $uiPage == 0 + Return + ${EndIf} + + ; 标题栏两个按钮每页都在 + !insertmacro Track $hMin $stMin "tb-min" ${TB_MIN_X} 0 ${TB_BTN_W} ${TB_H} + !insertmacro Track $hClose $stClose "tb-close" ${TB_CLOSE_X} 0 ${TB_BTN_W} ${TB_H} + + ${If} $uiPage == 1 + !insertmacro Track $hNext $stNext "btn-next" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + !insertmacro Track $hCancel $stCancel "btn-cancel" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + ${ElseIf} $uiPage == 2 + !insertmacro Track $hNext $stNext "btn-next" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + !insertmacro Track $hCancel $stCancel "btn-cancel" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + ${ElseIf} $uiPage == 3 + !insertmacro Track $hNext $stNext "btn-next" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + !insertmacro Track $hBack $stBack "btn-back" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + !insertmacro Track $hBrowse $stBrowse "btn-browse" ${BROWSE_X} ${BROWSE_Y} ${BROWSE_W} ${BROWSE_H} + ${ElseIf} $uiPage == 4 + !insertmacro Track $hCancel $stCancel "btn-cancel" ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + ; 安装在这里开工:等第一个 tick(窗口已经画完)再动手,然后完工后翻到 P5。 + ; 为什么不用 NSIS 自带的 instfiles 页:那一页在跑安装时只泵它自己的消息, + ; 我们挂在 StdUtils 定时器上的悬停/进度刷新全都收不到回调(实测:P4 上按钮不悬停、 + ; 定时器不触发)。改成自绘页 + 定时器驱动,消息泵一直是通的。 + ${If} $instStarted == 0 + StrCpy $instStarted 1 + Call DoInstall + GetDlgItem $0 $HWNDPARENT 1 + SendMessage $0 ${BM_CLICK} 0 0 + ${EndIf} + ${ElseIf} $uiPage == 5 + !insertmacro Track $hFinish $stFinish "btn-finish" ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} + ${EndIf} + + ; 标题栏拖动。用系统拖动循环(HTCAPTION)而不是自己算位移:跟手、贴边、最大化吸附都免费。 + ; 判定用「屏幕级的左键状态」,所以必须把标题栏按钮那块从拖动区里扣掉, + ; 否则点最小化/关闭会连带开始拖窗(POC 踩过)。 + ${If} $gCy >= 0 + ${AndIf} $gCy < ${TB_H} + ${AndIf} $gCx >= 0 + ${AndIf} $gCx < ${TB_MIN_X} + System::Call 'user32::GetAsyncKeyState(i 1) i .R3' + IntOp $R3 $R3 & 0x8000 + ${If} $R3 != 0 + ${If} $dragging != 1 + StrCpy $dragging 1 + System::Call '*(i, i) p .R4' + System::Call 'user32::GetCursorPos(p R4)' + System::Call '*$R4(i .R5, i .R6)' + System::Free $R4 + IntOp $R5 $R5 & 0xFFFF + IntOp $R6 $R6 & 0xFFFF + IntOp $R6 $R6 << 16 + IntOp $R5 $R5 | $R6 + System::Call 'user32::ReleaseCapture()' + System::Call 'user32::SendMessageW(p $HWNDPARENT, i ${WM_NCLBUTTONDOWN}, p 2, p $R5)' + ${EndIf} + ${Else} + StrCpy $dragging 0 + ${EndIf} + ${Else} + StrCpy $dragging 0 + ${EndIf} +FunctionEnd + +; ── 素材嵌入 ─────────────────────────────────────────────────────────────── +; 逐个文件写 File 而不是 File /r:素材缺一张就在编译期报错, +; 而不是运行期画出一块空白(装机现场没有回头补素材的机会)。 +!macro ArtFiles SUB + SetOutPath "$PLUGINSDIR\${SUB}" + File /oname=base.bmp "${ART_SRC}\${SUB}\base.bmp" + File /oname=brand.bmp "${ART_SRC}\${SUB}\brand.bmp" + File /oname=titlebar.bmp "${ART_SRC}\${SUB}\titlebar.bmp" + File /oname=page-welcome.bmp "${ART_SRC}\${SUB}\page-welcome.bmp" + File /oname=page-license.bmp "${ART_SRC}\${SUB}\page-license.bmp" + File /oname=page-dir.bmp "${ART_SRC}\${SUB}\page-dir.bmp" + File /oname=page-progress.bmp "${ART_SRC}\${SUB}\page-progress.bmp" + File /oname=page-finish.bmp "${ART_SRC}\${SUB}\page-finish.bmp" + File /oname=btn-next-normal.bmp "${ART_SRC}\${SUB}\btn-next-normal.bmp" + File /oname=btn-next-hover.bmp "${ART_SRC}\${SUB}\btn-next-hover.bmp" + File /oname=btn-next-pressed.bmp "${ART_SRC}\${SUB}\btn-next-pressed.bmp" + File /oname=btn-install-normal.bmp "${ART_SRC}\${SUB}\btn-install-normal.bmp" + File /oname=btn-install-hover.bmp "${ART_SRC}\${SUB}\btn-install-hover.bmp" + File /oname=btn-install-pressed.bmp "${ART_SRC}\${SUB}\btn-install-pressed.bmp" + File /oname=btn-finish-normal.bmp "${ART_SRC}\${SUB}\btn-finish-normal.bmp" + File /oname=btn-finish-hover.bmp "${ART_SRC}\${SUB}\btn-finish-hover.bmp" + File /oname=btn-finish-pressed.bmp "${ART_SRC}\${SUB}\btn-finish-pressed.bmp" + File /oname=btn-cancel-normal.bmp "${ART_SRC}\${SUB}\btn-cancel-normal.bmp" + File /oname=btn-cancel-hover.bmp "${ART_SRC}\${SUB}\btn-cancel-hover.bmp" + File /oname=btn-cancel-pressed.bmp "${ART_SRC}\${SUB}\btn-cancel-pressed.bmp" + File /oname=btn-back-normal.bmp "${ART_SRC}\${SUB}\btn-back-normal.bmp" + File /oname=btn-back-hover.bmp "${ART_SRC}\${SUB}\btn-back-hover.bmp" + File /oname=btn-back-pressed.bmp "${ART_SRC}\${SUB}\btn-back-pressed.bmp" + File /oname=btn-browse-normal.bmp "${ART_SRC}\${SUB}\btn-browse-normal.bmp" + File /oname=btn-browse-hover.bmp "${ART_SRC}\${SUB}\btn-browse-hover.bmp" + File /oname=btn-browse-pressed.bmp "${ART_SRC}\${SUB}\btn-browse-pressed.bmp" + File /oname=tb-min-normal.bmp "${ART_SRC}\${SUB}\tb-min-normal.bmp" + File /oname=tb-min-hover.bmp "${ART_SRC}\${SUB}\tb-min-hover.bmp" + File /oname=tb-min-pressed.bmp "${ART_SRC}\${SUB}\tb-min-pressed.bmp" + File /oname=tb-close-normal.bmp "${ART_SRC}\${SUB}\tb-close-normal.bmp" + File /oname=tb-close-hover.bmp "${ART_SRC}\${SUB}\tb-close-hover.bmp" + File /oname=tb-close-pressed.bmp "${ART_SRC}\${SUB}\tb-close-pressed.bmp" + File /oname=check-off.bmp "${ART_SRC}\${SUB}\check-off.bmp" + File /oname=check-on.bmp "${ART_SRC}\${SUB}\check-on.bmp" + File /oname=radio-off.bmp "${ART_SRC}\${SUB}\radio-off.bmp" + File /oname=radio-on.bmp "${ART_SRC}\${SUB}\radio-on.bmp" + File /oname=progress-0.bmp "${ART_SRC}\${SUB}\progress-0.bmp" + File /oname=progress-1.bmp "${ART_SRC}\${SUB}\progress-1.bmp" + File /oname=progress-2.bmp "${ART_SRC}\${SUB}\progress-2.bmp" + File /oname=progress-3.bmp "${ART_SRC}\${SUB}\progress-3.bmp" + File /oname=progress-4.bmp "${ART_SRC}\${SUB}\progress-4.bmp" + File /oname=progress-5.bmp "${ART_SRC}\${SUB}\progress-5.bmp" + File /oname=stage-1.bmp "${ART_SRC}\${SUB}\stage-1.bmp" + File /oname=stage-2.bmp "${ART_SRC}\${SUB}\stage-2.bmp" + File /oname=stage-3.bmp "${ART_SRC}\${SUB}\stage-3.bmp" + File /oname=stage-4.bmp "${ART_SRC}\${SUB}\stage-4.bmp" + File /oname=stage-5.bmp "${ART_SRC}\${SUB}\stage-5.bmp" +!macroend + +; 只解当前 DPI 需要的那一套:两套都解要写 26MB 临时文件,首次显示会明显卡一下。 +Function EnsureArt + ${If} $artReady == 1 + Return + ${EndIf} + StrCpy $artReady 1 + InitPluginsDir + ${If} $gDpi >= 144 + StrCpy $gArt "$PLUGINSDIR\2x" + !insertmacro ArtFiles 2x + ${Else} + StrCpy $gArt "$PLUGINSDIR\1x" + !insertmacro ArtFiles 1x + ${EndIf} +FunctionEnd + +; 系统 DPI。ManifestDPIAware 之后进程不会被人为放大,只能自己按这个数缩放版面。 +Function PickDpi + System::Call 'user32::GetDC(p 0) p .R0' + System::Call 'gdi32::GetDeviceCaps(p R0, i 88) i .R1' ; LOGPIXELSX + System::Call 'user32::ReleaseDC(p 0, p R0)' + ${If} $R1 < 96 + StrCpy $R1 96 + ${EndIf} + StrCpy $gDpi $R1 +FunctionEnd + +; 内层对话框铺满整个客户区:MUI 外层框架(页眉 / 品牌字 / 原生按钮条)全被盖住, +; 自绘位图才有完整的 760×480 画布。 +Function NewPage + nsDialogs::Create 1018 + Pop $hPage + ${If} $hPage == error + Abort + ${EndIf} + ; 悬停/拖动全靠这个 60ms 轮询(POC 结论:比窗口子类化稳,代价是光标移动要等一个 tick)。 + ; 建在这里而不是 .onGUIInit:StdUtils 的定时器要挂在页对话框上才跑得起来。 + ${If} $timerId == 0 + ${StdUtils.TimerCreate} $timerId Tick 60 + ${EndIf} + Push ${WIN_W} + Call S + Pop $R0 + Push ${WIN_H} + Call S + Pop $R1 + System::Call 'user32::SetWindowPos(p $hPage, p 0, i 0, i 0, i $R0, i $R1, i 0x0004)' + ; 上一页的句柄全部作废:定时器靠这组变量判断「还能碰谁」 + StrCpy $hBg 0 + StrCpy $hBrand 0 + StrCpy $hTitleBar 0 + StrCpy $hMin 0 + StrCpy $hClose 0 + StrCpy $hNext 0 + StrCpy $hBack 0 + StrCpy $hCancel 0 + StrCpy $hBrowse 0 + StrCpy $hFinish 0 + StrCpy $hCheck 0 + StrCpy $hStage 0 + StrCpy $hProg 0 + StrCpy $stMin 0 + StrCpy $stClose 0 + StrCpy $stNext 0 + StrCpy $stBack 0 + StrCpy $stCancel 0 + StrCpy $stBrowse 0 + StrCpy $stFinish 0 +FunctionEnd + +; 全窗共用的底:背景 → 品牌栏 → 页面位图 → 标题栏 → 标题栏按钮。 +; 栈:页面位图名(空字符串 = 该页不贴页面位图,比如安装中页自己控制进度) +Function Chrome + Pop $tmpName + !insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" 0 0 ${WIN_W} ${WIN_H} $hBg + !insertmacro SetBmpStatic $hBg "base.bmp" ${WIN_W} ${WIN_H} + !insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" 0 ${TB_H} ${SIDE_W} ${BRAND_H} $hBrand + !insertmacro SetBmpStatic $hBrand "brand.bmp" ${SIDE_W} ${BRAND_H} + ${If} $tmpName != "" + !insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${PG_X} ${PG_Y} ${PG_W} ${PG_H} $hProg + !insertmacro SetBmpStatic $hProg "$tmpName" ${PG_W} ${PG_H} + ${EndIf} + !insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" 0 0 ${WIN_W} ${TB_H} $hTitleBar + !insertmacro SetBmpStatic $hTitleBar "titlebar.bmp" ${WIN_W} ${TB_H} + !insertmacro NewBmpBtn ${TB_MIN_X} 0 ${TB_BTN_W} ${TB_H} $hMin "tb-min-normal.bmp" + !insertmacro NewBmpBtn ${TB_CLOSE_X} 0 ${TB_BTN_W} ${TB_H} $hClose "tb-close-normal.bmp" + ${NSD_OnClick} $hMin OnMin + ${NSD_OnClick} $hClose OnClose +FunctionEnd + +; 自绘文字标签:对话框默认底色是灰的,不显式设色就会在页面上贴出一块灰, +; 所以一律用「底色 = 内容区底色」把标签融进位图。 +; 栈(后压先弹):文本 字号 基准高 基准宽 基准y 基准x +!macro AddLabel BX BY BW BH SIZE TEXT + Push ${BX} + Push ${BY} + Push ${BW} + Push ${BH} + Push ${SIZE} + Push "${TEXT}" + Call Label +!macroend + +Function Label + Pop $R0 + Pop $R1 + Pop $R2 + Pop $R3 + Pop $R4 + Pop $R5 + Push $R5 + Call S + Pop $R5 + Push $R4 + Call S + Pop $R4 + Push $R3 + Call S + Pop $R3 + Push $R2 + Call S + Pop $R2 + nsDialogs::CreateControl "Static" "${SS_LEFT}|${SS_CENTERIMAGE}|${WS_CHILD}|${WS_VISIBLE}" 0 $R5 $R4 $R3 $R2 "" + Pop $R6 + SetCtlColors $R6 "${CLR_TXT}" "${CLR_BG}" + CreateFont $R7 "Microsoft YaHei UI" "$R1" "400" + SendMessage $R6 ${WM_SETFONT} $R7 1 + ${NSD_SetText} $R6 "$R0" +FunctionEnd + +; 栈(后压先弹):样式 基准高 基准宽 基准y 基准x → 返回控件句柄 +!macro NewEdit STYLE BX BY BW BH OUT + Push ${BH} + Push ${BW} + Push ${BY} + Push ${BX} + Push "${STYLE}" + Call MakeEdit + Pop ${OUT} +!macroend + +Function MakeEdit + Pop $R5 + Pop $R0 + Pop $R1 + Pop $R2 + Pop $R3 + Push $R0 + Call S + Pop $R0 + Push $R1 + Call S + Pop $R1 + Push $R2 + Call S + Pop $R2 + Push $R3 + Call S + Pop $R3 + nsDialogs::CreateControl "Edit" "$R5" 0 $R0 $R1 $R2 $R3 "" + Pop $R6 + SetCtlColors $R6 "${CLR_TXT}" "0xffffff" + CreateFont $R7 "Microsoft YaHei UI" "12" "400" + SendMessage $R6 ${WM_SETFONT} $R7 1 + Push $R6 +FunctionEnd + +; ── 按钮动作 ─────────────────────────────────────────────────────────────── +; 标准按钮条被自绘层盖住了,但按钮还在:点它等于点系统按钮,翻页/取消都由 NSIS 自己走。 +Function GoNext + GetDlgItem $0 $HWNDPARENT 1 + SendMessage $0 ${BM_CLICK} 0 0 +FunctionEnd + +Function GoBack + GetDlgItem $0 $HWNDPARENT 3 + SendMessage $0 ${BM_CLICK} 0 0 +FunctionEnd + +Function GoCancel + GetDlgItem $0 $HWNDPARENT 2 + SendMessage $0 ${BM_CLICK} 0 0 +FunctionEnd + +Function OnMin + ; 最小化走系统命令:自绘窗口没有系统按钮,但 ShowWindow 一样有效 + System::Call 'user32::ShowWindow(p $HWNDPARENT, i 6)' ; SW_MINIMIZE +FunctionEnd + +Function OnClose + Call GoCancel +FunctionEnd + +Function OnLicCheck + ; 用 != 1 而不是 == 0:LogicLib 的 == 是字符串比较,$licOk 初值是空串,"" == 0 为假 + ${If} $licOk != 1 + StrCpy $licOk 1 + !insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16 + ${Else} + StrCpy $licOk 0 + !insertmacro SetBmpBase $hCheck "check-off.bmp" 16 16 + ${EndIf} +FunctionEnd + +Function OnLicNext + ${If} $licOk != 1 + ; 没同意就不让走:这是许可页唯一的硬性要求 + MessageBox MB_OK|MB_ICONINFORMATION|MB_TOPMOST "请先勾选「我已阅读并同意」再继续。" + Return + ${EndIf} + Call GoNext +FunctionEnd + +Function OnModeUser + StrCpy $modeAll 0 + !insertmacro SetBmpBase $hRadio1 "radio-on.bmp" 16 16 + !insertmacro SetBmpBase $hRadio2 "radio-off.bmp" 16 16 +FunctionEnd + +Function OnModeAll + StrCpy $modeAll 1 + !insertmacro SetBmpBase $hRadio1 "radio-off.bmp" 16 16 + !insertmacro SetBmpBase $hRadio2 "radio-on.bmp" 16 16 +FunctionEnd + +; 「浏览…」:系统文件夹选择框没有 NSIS 包装,用 System::Call 直接调 shell32。 +; BROWSEINFO = { hwndOwner, pidlRoot, pszDisplayName, lpszTitle, ulFlags, lpfn, lParam, iImage } +; ulFlags = BIF_RETURNONLYFSDIRS(0x1) | BIF_NEWDIALOGSTYLE(0x40) +Function OnBrowse + System::Call 'ole32::CoInitialize(p 0)' + StrCpy $R6 $HWNDPARENT + System::Alloc 260 + Pop $R7 + System::Alloc 32 + Pop $R0 + System::Call '*$R0(p $R6, p 0, p $R7, p 0, i 0x41, p 0, p 0, i 0)' + System::Call 'shell32::SHBrowseForFolder(p R0) p .R1' + ${If} $R1 != 0 + System::Call 'shell32::SHGetPathFromIDList(p R1, t .R2)' + System::Call 'ole32::CoTaskMemFree(p R1)' + ${If} $R2 != "" + StrCpy $tmpTxt $R2 + ${NSD_SetText} $hPath "$tmpTxt" + ${EndIf} + ${EndIf} + System::Free $R7 + System::Free $R0 +FunctionEnd + +Function OnFinish + ${If} $runApp == 1 + Call StartAppNow + ${EndIf} + ; 结束安装器。这里踩过两个坑:页面回调里 Quit 不生效(窗口不退、反而被推去下一页), + ; 对主窗口发 WM_CLOSE 也不生效;能真正退出的只有点系统那个「取消」按钮。 + ; 先把错误码归零,别让「正常装完」的退出码看起来像被取消。 + SetErrorLevel 0 + GetDlgItem $0 $HWNDPARENT 2 + SendMessage $0 ${BM_CLICK} 0 0 +FunctionEnd + +Function OnFinCheck + ${If} $runApp != 1 + StrCpy $runApp 1 + !insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16 + ${Else} + StrCpy $runApp 0 + !insertmacro SetBmpBase $hCheck "check-off.bmp" 16 16 + ${EndIf} +FunctionEnd + +; ── 进度分段 ─────────────────────────────────────────────────────────────── +; app-64.7z 是一次 Nsis7z::Extract 调用,拿不到百分比;所以进度按「宏观步骤」分段 +; (解压 → 注册表 → 快捷方式 → 防火墙 → 收尾),不画假进度条。 +; NSIS 的 Call 不收参数,用法统一走这个宏 +!macro StageCall N + Push ${N} + Call Stage +!macroend + +; 栈:段号 1..5 +Function Stage + Pop $R0 + ${If} ${Silent} + Return + ${EndIf} + ${If} $uiPage != 4 + Return + ${EndIf} + ; 段号先搬进全局变量:SetBmp 会踩掉 $R0..$R7(它的局部值都放那儿) + StrCpy $tmpTxt $R0 + StrCpy $tmpPath "stage-$tmpTxt.bmp" + Push ${STAGE_H} + Push ${STAGE_W} + Push ${STM_SETIMAGE} + Push $hStage + Push $tmpPath + Call SetBmp + StrCpy $tmpPath "progress-$tmpTxt.bmp" + Push ${PROG_H} + Push ${PROG_W} + Push ${STM_SETIMAGE} + Push $hProg + Push $tmpPath + Call SetBmp +FunctionEnd + +; ── P1 欢迎 ──────────────────────────────────────────────────────────────── +Function PageWelcome + ${If} ${Silent} + Abort + ${EndIf} + StrCpy $uiPage 1 + Call NewPage + Push "page-welcome.bmp" + Call Chrome + !insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hCancel "btn-cancel-normal.bmp" + !insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hNext "btn-next-normal.bmp" + ${NSD_OnClick} $hCancel OnClose + ${NSD_OnClick} $hNext GoNext + nsDialogs::Show +FunctionEnd + +; ── P2 许可协议 ──────────────────────────────────────────────────────────── +; 中文摘要烘焙在 page-license.bmp 里;英文原文(LICENSE)用真编辑框显示, +; 因为它比框大一截、必须能滚 —— 位图放不下整篇协议。 +Function PageLicense + ${If} ${Silent} + Abort + ${EndIf} + StrCpy $uiPage 2 + Call NewPage + Push "page-license.bmp" + Call Chrome + + ; 先按可写建,灌完文本再设只读:以只读样式创建的编辑框会被刷成灰底, + ; 后期用 EM_SETREADONLY 设的只读框底色还是白的(SetCtlColors 再兜一层)。 + !insertmacro NewEdit "${ES_MULTILINE}|${ES_AUTOVSCROLL}|${WS_VSCROLL}|${WS_CHILD}|${WS_VISIBLE}" ${LIC_EDIT_X} ${LIC_EDIT_Y} ${LIC_EDIT_W} ${LIC_EDIT_H} $hLicEdit + File /oname=$PLUGINSDIR\license.txt "${PROJECT_DIR}\..\LICENSE" + FileOpen $9 "$PLUGINSDIR\license.txt" r + lic_loop: + ClearErrors + FileRead $9 $8 + IfErrors lic_done + SendMessage $hLicEdit ${EM_REPLACESEL} 1 "STR:$8$\r$\n" + Goto lic_loop + lic_done: + FileClose $9 + ; FileRead + EM_REPLACESEL 会把插入点一路推到末尾、视图跟着滚到底; + ; 用户进这一页看到的应该是协议开头,所以显式回到顶部 + SendMessage $hLicEdit ${EM_SETSEL} 0 0 + SendMessage $hLicEdit ${WM_VSCROLL} 6 0 ; SB_TOP + SendMessage $hLicEdit ${EM_SETREADONLY} 1 0 + + !insertmacro NewBmpBtn 272 432 16 16 $hCheck "check-off.bmp" + ${If} $licOk == 1 + !insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16 + ${EndIf} + !insertmacro AddLabel 296 430 300 20 12 "我已阅读并同意上述条款" + ${NSD_OnClick} $hCheck OnLicCheck + + !insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hCancel "btn-cancel-normal.bmp" + !insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hNext "btn-next-normal.bmp" + ${NSD_OnClick} $hCancel OnClose + ${NSD_OnClick} $hNext OnLicNext + nsDialogs::Show +FunctionEnd + +Function PageLicenseLeave + StrCpy $uiPage 0 +FunctionEnd + +; ── P3 安装位置 + 安装模式 ───────────────────────────────────────────────── +Function PageDir + ${If} ${Silent} + Abort + ${EndIf} + StrCpy $uiPage 3 + Call NewPage + Push "page-dir.bmp" + Call Chrome + + !insertmacro NewEdit "${ES_AUTOHSCROLL}|${WS_CHILD}|${WS_VISIBLE}" ${PATH_EDIT_X} ${PATH_EDIT_Y} ${PATH_EDIT_W} ${PATH_EDIT_H} $hPath + ${NSD_SetText} $hPath "$INSTDIR" + !insertmacro NewBmpBtn ${BROWSE_X} ${BROWSE_Y} ${BROWSE_W} ${BROWSE_H} $hBrowse "btn-browse-normal.bmp" + ${NSD_OnClick} $hBrowse OnBrowse + + !insertmacro NewBmpBtn ${RADIO1_X} ${RADIO1_Y} 16 16 $hRadio1 "radio-off.bmp" + !insertmacro NewBmpBtn ${RADIO2_X} ${RADIO2_Y} 16 16 $hRadio2 "radio-off.bmp" + ${NSD_OnClick} $hRadio1 OnModeUser + ${NSD_OnClick} $hRadio2 OnModeAll + ; 初始勾选跟随 $installMode:initMultiUser 已经按「上次装法 / 命令行 / 默认」定好了模式 + ${If} $installMode == "all" + StrCpy $modeAll 1 + !insertmacro SetBmpBase $hRadio2 "radio-on.bmp" 16 16 + ${Else} + StrCpy $modeAll 0 + !insertmacro SetBmpBase $hRadio1 "radio-on.bmp" 16 16 + ${EndIf} + + ; 位图上的「需要空间 约 260 MB」是设计稿的占位数字,用真实体积盖掉 + !ifdef ESTIMATED_SIZE + IntOp $R0 ${ESTIMATED_SIZE} / 1024 + StrCpy $tmpTxt "需要空间 约 $R0 MB" + !insertmacro AddLabel ${PG_X} ${SIZE_TXT_Y} ${CONTENT_W} 20 11 "$tmpTxt" + !endif + + !insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hBack "btn-back-normal.bmp" + !insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hNext "btn-next-normal.bmp" + ${NSD_OnClick} $hBack GoBack + ${NSD_OnClick} $hNext GoNext + nsDialogs::Show +FunctionEnd + +Function PageDirLeave + StrCpy $uiPage 0 + ; 手改过就以输入框为准(浏览按钮也是写回这个框) + ${NSD_GetText} $hPath $R1 + ${If} $R1 != "" + StrCpy $INSTDIR $R1 + ${EndIf} + StrCpy $R2 $INSTDIR 1 -1 + ${If} $R2 == "\" + StrCpy $INSTDIR $INSTDIR -1 + ${EndIf} + ; 和 electron-builder 的目录页同一套规矩:路径必须以应用目录名结尾, + ; 否则用户填 D:\Games 会把整个文件树倒进 D:\Games 而不是 D:\Games\Scoreboard_Console + ${StrContains} $R3 "${APP_FILENAME}" $INSTDIR + ${If} $R3 == "" + StrCpy $INSTDIR "$INSTDIR\${APP_FILENAME}" + ${EndIf} + + ${If} $modeAll == 1 + ; 「为所有用户安装」:照抄 multiUserUi.nsh 的提权范式。 + ; 提权成功后是**另一个进程**在跑整个向导(它已经是管理员,会重新走到这一页并直接接着往下), + ; 外层进程在 ${Case} 0 里 Quit,避免两个进程同时写同一份文件。 + ${IfNot} ${UAC_IsAdmin} + ShowWindow $HWNDPARENT ${SW_HIDE} + !insertmacro UAC_RunElevated + ${Switch} $0 + ${Case} 0 + ${If} $1 = 1 + Quit + ${EndIf} + ${If} $1 = 3 + ${OrIf} $2 = 0x666666 + MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "$(loginWithAdminAccount)" + ${EndIf} + ${Break} + ${Case} 1223 ;用户取消了 UAC + ${Break} + ${Case} 1062 + MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "登录服务未运行,无法提权。" + ${Break} + ${Default} + MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "提权失败,错误码 $0" + ${Break} + ${EndSwitch} + ShowWindow $HWNDPARENT ${SW_SHOW} + BringToFront + Abort ; 留在本页:提权没成时让用户改选「仅为我安装」 + ${Else} + !insertmacro setInstallModePerAllUsers + ${EndIf} + ${Else} + !insertmacro setInstallModePerUser + ${EndIf} +FunctionEnd + +; ── P4 正在安装 ──────────────────────────────────────────────────────────── +; 自绘页:分段进度由 DoInstall 里的 Stage 调用推动(7z 一次解压拿不到百分比)。 +; 不用 NSIS 自带的 instfiles 页,原因见 Tick 里的注释。 +Function PageInstall + ${If} ${Silent} + Abort + ${EndIf} + StrCpy $uiPage 4 + Call NewPage + Push "page-progress.bmp" + Call Chrome + !insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${STAGE_X} ${STAGE_Y} ${STAGE_W} ${STAGE_H} $hStage + !insertmacro SetBmpStatic $hStage "stage-1.bmp" ${STAGE_W} ${STAGE_H} + !insertmacro NewCtl "Static" "${SS_BITMAP}|${WS_CHILD}|${WS_VISIBLE}" ${PROG_X} ${PROG_Y} ${PROG_W} ${PROG_H} $hProg + !insertmacro SetBmpStatic $hProg "progress-0.bmp" ${PROG_W} ${PROG_H} + !insertmacro NewBmpBtn ${BTN_SECONDARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hCancel "btn-cancel-normal.bmp" + ${NSD_OnClick} $hCancel OnClose + nsDialogs::Show +FunctionEnd + +Function PageInstallLeave + StrCpy $uiPage 0 +FunctionEnd + +; ── P5 完成 ──────────────────────────────────────────────────────────────── +Function PageFinish + ${If} ${Silent} + Abort + ${EndIf} + StrCpy $uiPage 5 + Call NewPage + Push "page-finish.bmp" + Call Chrome + + !insertmacro NewBmpBtn ${FIN_CHK_X} ${FIN_CHK_Y} 16 16 $hCheck "check-off.bmp" + ${If} $runApp == 1 + !insertmacro SetBmpBase $hCheck "check-on.bmp" 16 16 + ${EndIf} + !insertmacro AddLabel 296 336 300 20 13 "立即运行 赛事积分榜" + ${NSD_OnClick} $hCheck OnFinCheck + + ; 台上的实际安装目录(位图里写的是设计稿的占位路径) + StrCpy $tmpTxt "安装位置:$INSTDIR" + !insertmacro AddLabel ${PG_X} ${FIN_PATH_Y} ${CONTENT_W} 18 11 "$tmpTxt" + + !insertmacro NewBmpBtn ${BTN_PRIMARY_X} ${BTN_Y} ${BTN_W} ${BTN_H} $hFinish "btn-finish-normal.bmp" + ${NSD_OnClick} $hFinish OnFinish + nsDialogs::Show +FunctionEnd + +Function PageFinishLeave + StrCpy $uiPage 0 +FunctionEnd + +; ── 界面初始化 ───────────────────────────────────────────────────────────── +; 去系统边框 + 固定 760×480(按 DPI 缩放)+ 居中。 +; MUI 的外层框架本身用不上,但它是 $HWNDPARENT、是标准按钮条的宿主, +; 所以不能不要,只能去边框、盖住、把标准按钮当程序化翻页的工具。 +Function GuiInit + Call PickDpi + System::Call 'user32::GetWindowLong(p $HWNDPARENT, i -16) i .R0' + IntOp $R0 $R0 & 0xFF30FFFF ; 清 WS_CAPTION / BORDER / DLGFRAME / SYSMENU / MIN / MAX + System::Call 'user32::SetWindowLong(p $HWNDPARENT, i -16, i R0)' + Push ${WIN_W} + Call S + Pop $R0 + Push ${WIN_H} + Call S + Pop $R1 + System::Call 'user32::GetSystemMetrics(i 0) i .R2' + System::Call 'user32::GetSystemMetrics(i 1) i .R3' + IntOp $R2 $R2 - $R0 + IntOp $R2 $R2 / 2 + IntOp $R3 $R3 - $R1 + IntOp $R3 $R3 / 2 + ${If} $R2 < 0 + StrCpy $R2 0 + ${EndIf} + ${If} $R3 < 0 + StrCpy $R3 0 + ${EndIf} + System::Call 'user32::SetWindowPos(p $HWNDPARENT, p 0, i $R2, i $R3, i $R0, i $R1, i 0x0014)' + Call EnsureArt +FunctionEnd + +; ── .onInit ──────────────────────────────────────────────────────────────── +; 逐项对照 electron-builder 的 templates/nsis/installer.nsi,只去掉 setInstallSectionSpaceRequired +; (那是给组件页算体积用的,我们没有组件页)。 +Function .onInit + ; 显式赋 "0":下面 NewPage 里用 $timerId == 0 判断「建过没有」, + ; 而 LogicLib 的 == 是字符串比较,空串跟 0 不相等,会一次都不建(踩过) + StrCpy $timerId 0 + StrCpy $uiPage 0 + StrCpy $instStarted 0 + SetOutPath $INSTDIR + ${LogSet} on + !insertmacro check64BitAndSetRegView + ; 静默安装跳过单实例检查:这个宏的头一句是 BringToFront,而静默模式压根没有窗口, + ; 实测在这种情况下会挂死(wine 下 45 秒不返回 —— 安装器就卡在这儿永远装不完)。 + ; 交互安装照常查:那才是「用户重复双击」真正会发生的地方。 + ${IfNot} ${Silent} + ${AndIfNot} ${UAC_IsInnerInstance} + !insertmacro ALLOW_ONLY_ONE_INSTALLER_INSTANCE + ${EndIf} + !insertmacro initMultiUser + !ifmacrodef customInit + !insertmacro customInit + !endif + StrCpy $runApp 1 +FunctionEnd + +; ── 页面序列 ─────────────────────────────────────────────────────────────── + +Page custom PageWelcome +Page custom PageLicense PageLicenseLeave +Page custom PageDir PageDirLeave + +Page custom PageInstall PageInstallLeave +Page custom PageFinish PageFinishLeave + +; 空跑的 instfiles 页(放在页面序列最后):NSIS 要求「有安装段就必须有 instfiles 页」, +; 否则 -WX 下直接报 warning 8000 编译不过。在 PRE 回调里 Abort 掉、页面不会显示, +; 而 P5 的「完成」是直接结束安装器(见 OnFinish),根本走不到这一页。 +PageEx instfiles + Caption " " + PageCallbacks SkipInstfiles "" "" +PageExEnd + +Function SkipInstfiles + Abort +FunctionEnd + +; ── 安装流程 ─────────────────────────────────────────────────────────────── +; 「立即运行」用 electron-builder 的 StartApp 宏(common.nsh):它内部走 ExecShellAsUser, +; 以**登录用户**身份启动 —— 「为所有用户安装」那一趟进程是提权过的,直接 Exec 会让应用 +; 继承管理员令牌,装完跑起来的会是个管理员身份的客户端(数据目录/开机自启都跑到别人家去)。 +; 这个宏里带一句 Var /GLOBAL startAppArgs,整个脚本只能插入一次,所以包成一个函数给两处调。 +Function StartAppNow + HideWindow + !insertmacro StartApp +FunctionEnd + +; 安装流程本体。放在函数里而不是 Section 里,是为了让 P4 能是自绘页(原因见 Tick)。 +; 顺序逐行对照 electron-builder 的 templates/nsis/installSection.nsh。 +Function DoInstall + ; 静默安装没有 UI 可选安装模式,per-machine 这一趟要在这里补提权。 + ; 比上游多一个 $installMode == "all" 的条件:上游只在「已有 per-machine 安装」时提权, + ; 而我们把默认模式改成了「为所有用户」,全新静默安装(/S)就会默认往 Program Files 写, + ; 不提权必然写不进去 —— 现场用脚本装机时这一步比交互装机更要紧。 + !ifndef INSTALL_MODE_PER_ALL_USERS + !ifndef ONE_CLICK + ${if} $installMode == "all" + ${andIf} ${Silent} + ${ifNot} ${UAC_IsAdmin} + ShowWindow $HWNDPARENT ${SW_HIDE} + !insertmacro UAC_RunElevated + ${Switch} $0 + ${Case} 0 + ${Break} + ${Case} 1223 ;用户取消了 UAC + ${Break} + ${Default} + MessageBox mb_IconStop|mb_TopMost|mb_SetForeground "Unable to elevate, error $0" + ${Break} + ${EndSwitch} + Quit + ${else} + !insertmacro setInstallModePerAllUsers + ${endIf} + ${endIf} + !endif + !endif + + InitPluginsDir + + ${IfNot} ${Silent} + SetDetailsPrint none + ${endif} + + StrCpy $appExe "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + + # must be called before uninstallOldVersion + !insertmacro setLinkVars + + ; 非 one-click 时只在最外层进程里查:提权出来的内层进程和用户看到的可能是同一个应用, + ; 两个进程同时抢着关它会互相打架(上游 installer.nsh / installSection.nsh 就是这么分的)。 + ; 静默安装没有窗口,那句 UAC_IsInnerInstance 走插件、会卡住不返回,所以直接查。 + ${If} ${Silent} + Call CheckAppRunningImpl + ${Else} + ${ifNot} ${UAC_IsInnerInstance} + Call CheckAppRunningImpl + ${endif} + ${EndIf} + + StrCpy $keepShortcuts "false" + !insertMacro setIsTryToKeepShortcuts + ${if} $isTryToKeepShortcuts == "true" + ReadRegStr $R1 SHELL_CONTEXT "${INSTALL_REGISTRY_KEY}" KeepShortcuts + ${if} $R1 == "true" + ${andIf} ${FileExists} "$appExe" + StrCpy $keepShortcuts "true" + ${endIf} + ${endif} + + ; 覆盖安装:先跑旧卸载器(它自己会带 --updated / --keep-shortcuts) + !insertmacro uninstallOldVersion SHELL_CONTEXT + !insertmacro handleUninstallResult SHELL_CONTEXT + ${if} $installMode == "all" + !insertmacro uninstallOldVersion HKEY_CURRENT_USER + !insertmacro handleUninstallResult HKEY_CURRENT_USER + ${endIf} + + SetOutPath $INSTDIR + + !insertmacro StageCall 1 + !insertmacro installApplicationFiles + !insertmacro StageCall 2 + !insertmacro registryAddInstallInfo + !insertmacro StageCall 3 + !insertmacro addStartMenuLink $keepShortcuts + !insertmacro addDesktopLink $keepShortcuts + + ${if} ${FileExists} "$newStartMenuLink" + StrCpy $launchLink "$newStartMenuLink" + ${else} + StrCpy $launchLink "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + ${endif} + + !ifmacrodef registerFileAssociations + !insertmacro registerFileAssociations + !endif + + !insertmacro StageCall 4 + !ifmacrodef customInstall + !insertmacro customInstall + !endif + !insertmacro StageCall 5 + + ; 真卸载器在这里现写。先删掉 installApplicationFiles 落下来的那个占位文件, + ; 免得「万一没写成功、$INSTDIR 里却留着一个假卸载器」这种最坏情况。 + Delete "$INSTDIR\${UNINSTALL_FILENAME}" + WriteUninstaller "$INSTDIR\${UNINSTALL_FILENAME}" + + + + !ifdef ONE_CLICK + !ifdef RUN_AFTER_FINISH + ${ifNot} ${Silent} + ${orIf} ${isForceRun} + Call StartAppNow + ${endIf} + !else + ${if} ${isForceRun} + Call StartAppNow + ${endIf} + !endif + !insertmacro quitSuccess + !else + ; 交互安装的「立即运行」在 P5 上处理;静默安装只能靠 --force-run + ${if} ${isForceRun} + ${andIf} ${Silent} + Call StartAppNow + ${endIf} + !endif +FunctionEnd + +; 安装段的两条路: +; 交互安装:安装段根本不会执行(页面序列里的 instfiles 页被 Abort 掉了), +; 安装流程由 P4 的定时器调 DoInstall 跑; +; 静默安装(/S):没有页面可走,段就是唯一入口 —— 这一步走的是 electron-builder +; 原生的「段在 instfiles 页上执行」,和上游同一条路。 +; 之所以不把静默安装也塞进 .onInit:实测那样会在 wine 里挂死(.onInit 阶段还没有 +; 页对话框,DoInstall 里的界面相关动作没有宿主)。段这条路是上游验证过的。 +Section "install" INSTALL_SECTION_ID + ${If} ${Silent} + Call DoInstall + !insertmacro quitSuccess + ${EndIf} +SectionEnd diff --git a/console/installer/uninstaller-stub.txt b/console/installer/uninstaller-stub.txt new file mode 100644 index 0000000..801520d --- /dev/null +++ b/console/installer/uninstaller-stub.txt @@ -0,0 +1,10 @@ +placeholder file for custom-script builds +======================================== + +本文件只是 electron-builder 模板宏 installApplicationFiles 需要的一个「肯定存在」的 +File 源文件。用了自定义 installer 脚本(build.nsis.script)之后 electron-builder 不再 +预编卸载器,UNINSTALLER_OUT_FILE 被留成空值,而那条 File 命令是无条件的 —— +不顶一个真文件上去,makensis 会直接以「no files found」失败。 + +安装时这个文件会被解到 $INSTDIR 下、紧接着被删掉,再由 WriteUninstaller 写出真正的 +卸载器(见 console/installer/installer.nsi 的安装段末尾)。它不会留在用户机器上。 diff --git a/console/package.json b/console/package.json index 0828522..360de96 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "scoreboard-console", "productName": "Scoreboard Console", - "version": "0.5.0", + "version": "0.5.1", "private": true, "author": "AskaEth", "description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏", @@ -73,12 +73,14 @@ "artifactName": "${productName}-${version}-${arch}.${ext}" }, "nsis": { + "script": "installer/installer.nsi", "oneClick": false, "allowToChangeInstallationDirectory": true, "createDesktopShortcut": true, "shortcutName": "赛事积分榜", "license": "../LICENSE", "include": "resources/installer-custom.nsh", + "selectPerMachineByDefault": true, "language": "2052" } } diff --git a/deploy/prepare-extra.mjs b/deploy/prepare-extra.mjs index 817970c..ad9e4d2 100644 --- a/deploy/prepare-extra.mjs +++ b/deploy/prepare-extra.mjs @@ -33,6 +33,21 @@ for (const doc of docsToStage) { console.log(` ${doc.label} staged from ${source}`) } +// ---- 1b. 自绘安装器脚本的编码检查 ------------------------------------------- +// 本机手工跑 makensis 时必须带 UTF-8 BOM,否则脚本里的中文直接被判成 Bad text encoding; +// electron-builder 那边传了 -INPUTCHARSET UTF8,构建机上没 BOM 也能过 —— 两边口径不一致, +// 出问题时只会表现为「本地编不过、构建机编得过」这种最难查的差异,所以统一要求带 BOM。 +const installerScript = join(root, 'console', 'installer', 'installer.nsi') +if (existsSync(installerScript)) { + const head = readFileSync(installerScript).subarray(0, 3) + if (!(head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf)) { + console.error(` [installer] ${installerScript} 缺少 UTF-8 BOM:本机 makensis 会报 Bad text encoding`) + console.error(' [installer] 修复:用编辑器另存为「UTF-8 with BOM」,或跑 python3 -c "…" 补 BOM') + process.exit(1) + } + console.log(' installer script has UTF-8 BOM') +} + // ---- 2. installer custom script --------------------------------------------- // electron-builder 的 nsis.include 只收一个文件,所以许可页文案、安装目录记忆与防火墙规则写在同一个 .nsh 里。 const signatureFile = join(root, 'deploy', 'signature.local.txt') @@ -171,6 +186,24 @@ if (version === '') { process.exit(1) } +// ---- 3b. 安装器素材的版本号核对 --------------------------------------------- +// 安装界面的版本号是「烘焙进位图」的(console/installer/art/*/brand.bmp 的品牌栏), +// 位图里读不出文本,所以 gen_art.py 另外写了一份 art/version.txt 作旁证。 +// 不核对的话会出现「安装界面写着 v0.5.1、装的其实是 0.5.2」——这种错只在装完之后 +// 被人一眼看到,而且很容易随包发出去;宁可构建直接失败,也不要发错版本号。 +const artDir = join(root, 'console', 'installer', 'art') +if (existsSync(artDir)) { + const artVersionFile = join(artDir, 'version.txt') + const artVersion = existsSync(artVersionFile) ? readFileSync(artVersionFile, 'utf8').trim() : '' + const wantVersion = version.replace(/^dev_/, '') + if (artVersion !== wantVersion) { + console.error(` [art] 安装器素材里的版本号与 VERSION 对不上:素材=${artVersion || '(缺失)'},VERSION=${wantVersion}`) + console.error(' [art] 修复:跑 python3 console/installer/art-gen/gen_art.py 重新生成素材,然后提交') + process.exit(1) + } + console.log(` installer art version matches (${wantVersion})`) +} + // 必须纯 ASCII:cmd.exe 会错解 .bat 里的非 ASCII 文本(见本文件头部说明)。 // 用 CRLF:与 .gitattributes 里 *.bat 的约定一致,cmd 在 Windows 上按 CRLF 读最稳。 // 只 set 不加 setlocal:build.bat 用 call 调它,变量要留在调用方的环境里。 diff --git a/docs/使用手册.md b/docs/使用手册.md index 61e97af..e9db23b 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -109,7 +109,9 @@ | 状态 | 下拉选择:**有效**、**DNS 弃赛**、**DQ 取消**、**DNF 未完赛**、**退赛** | | 积分(自动) | 随成绩实时计算,仅供参考 | | 备注 | 自由填写,示例文本是 `可留空` | -| 操作 | 已发布的行显示 **「作废本条」**(已作废的行改显示 **「重新录入」**);草稿行这里是 `—`(草稿本来就能直接改)。详见 4.10 | +| 操作 | 已发布的行并排两个按钮 **「修改本条」**(退回草稿改一改,改完重新发布本组)与 **「作废本条」**(这一条不算了);已作废的行改显示 **「重新录入」**;草稿行这里是 `—`(草稿本来就能直接改)。详见 4.10 | + +> 已发布的行里,**成绩格与状态格可以直接点**:点一下弹出确认框(写明会退回草稿、榜单先把它退下来、改完需重新发布本组),确认后才退回,点「取消」什么都不做。名次格与备注格点不动。 **成绩写法**:能接受的写法比过去多,中文输入法下的全角冒号 `1:06`、全角数字也认。 @@ -179,7 +181,7 @@ 3. 确认框会写明:`本组还有 N 人没有成绩,本次将发布已填的 M 人;其余保持未发布,补录后再发布即可。本次影响 … 个单位,最大分差变化 … 分。发布后立即上屏并重算积分榜。`——**看完再确认**,系统只发布已填的那 M 人,不会把没成绩的人当成 0 分发出去(末句在延迟结算下会换成「名次与榜单等该组全部录完后再更新」,见下面); 4. 确认后这批成绩立即上屏;**实时结算(默认)下,名次与积分先按已发布的这部分算**,大屏立刻有内容(延迟结算下先不动,见下); 5. 下一批跑完,回到本页补录成绩,再点一次「发布本批」——系统会把两批放在一起重算名次与积分,先发的部分自动跟着更新; -6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布。**只错一条**时不必撤销整组发布,用该行的「作废本条 → 重新录入」(见 4.10);整组都要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。 +6. 全部发完后按钮不再显示「本批」(并变为不可用),此时该组视为已全部发布。**只错一条**时不必撤销整组发布,点那一格(或该行的「修改本条」)退回草稿改完重发即可(见 4.10);整组都要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。 > 只有「会被算错」的写法才会挡住发布(见 4.4);空格子只是「还没录」,属于提醒,不影响发布。审核页(第 5 节)也是同一套口径:只有写错或缺名次才挡「发布整组」,空成绩只是本次跳过。 @@ -197,7 +199,7 @@ 2. 系统作废该组已产生的积分流水,大屏回退到发布前状态; 3. 修改后重新发布即可。全程留痕,可在「变更历史」查看。 -> **只错了一条成绩时**,不必撤销整组发布、把别人的成绩也一起翻下来——用该行「操作」列的「作废本条 / 重新录入」即可,见 4.10。 +> **只错了一条成绩时**,不必撤销整组发布、把别人的成绩也一起翻下来——点那一格(或该行的「修改本条」)退回草稿改完重发即可,见 4.10。 ### 4.8 勾选与批量操作(退赛 / 恢复 / 移出报名) @@ -236,33 +238,38 @@ - **权限**:读得到成绩的人都能看到这块卡片(访客只读,输入框置灰并写明原因);**能保存的是录入员及以上**(与成绩录入同权限)。示例数据模式下不能保存; - 团体名次是**独立保存**的:点「保存团体名次」只提交这块卡片,不会顺手发布成绩;成绩仍然要另外「发布」。 -### 4.10 单条作废 / 重新录入(手滑填错了怎么办) +### 4.10 单条修改 / 作废 / 重新录入(手滑填错了怎么办) -已发布的成绩行在表格里仍然是**锁着的**(成绩、名次、状态、备注都改不了)——这是有意的:已发布的行已经上屏、已经计分,允许就地改会让「看到的榜单」和「改到一半的数据」对不上。要改就必须先明确地把它退回来。表格最后一列 **「操作」** 就是这件事的入口,只有已发布的行才有按钮。 +已发布的成绩行在表格里仍然是**锁着的**(名次、备注改不了)——这是有意的:已发布的行已经上屏、已经计分,允许就地改会让「看到的榜单」和「改到一半的数据」对不上。要改就必须先明确地把它退回来。入口有两个,效果一样: -**两个按钮,什么时候用哪个** +- **直接点那一格**:已发布的**成绩格**与**状态格**点得动,点一下会弹出确认框,写明「这一条会退回草稿、榜单先把它退下来、改完需重新发布本组」;点「取消」**什么都不做**(成绩、榜单、大屏都不动),点确认才退回来。名次格与备注格仍然点不动; +- **点表格最右端「操作」列的按钮**,只有已发布的行才有按钮。 + +**三个按钮,什么时候用哪个** | 按钮 | 什么时候出现 | 做什么 | | --- | --- | --- | -| **作废本条** | 这一行是**已发布且正常**的成绩 | 让这**一条**成绩退出名次与积分(同组其它人重新排名),但**不改**它的原始记录——需要改回来时还是用「重新录入」 | -| **重新录入** | 这一行是**已作废**的(红徽标「已作废」) | 把这一条**退回草稿**(原来填的成绩还在格里,直接改就行)、不再计入名次与积分,之后就能像普通草稿一样编辑;改完再发布本组 | +| **修改本条** | 这一行是**已发布且正常**的成绩 | 把这一条**退回草稿**(原来填的成绩还在格里,直接改就行),名次与积分先按「没有这条成绩」重算,改完再发布本组。**只是填错了改用这个**——它不会把这一条标成「已作废」 | +| **作废本条** | 同上 | 让这**一条**成绩不再算数(同组其它人重新排名):成绩文字还留在格里,录入列变成红徽标「已作废」,**不计入名次与积分**。这一条确实不算了才用它 | +| **重新录入** | 这一行是**已作废**的(红徽标「已作废」) | 把这一条**退回草稿**(同「修改本条」),改完就能像普通草稿一样重新发布 | -两个按钮**都必须填写原因**(确认框里的「作废原因 / 退回原因」),留空时确认键点了也不会提交——原因会写进审计日志,事后能查清是谁、什么时候、为什么动的这一条。 +三个按钮**都必须填写原因**(确认框里的「修改原因 / 作废原因 / 退回原因」),留空时确认键点了也不会提交——原因会写进审计日志,事后能查清是谁、什么时候、为什么动的这一条。 **点完会立刻发生什么** - 服务端在同一个事务里**按当前已发布成绩重算**该小项该组的名次与积分,并**立即推送大屏**; - 这一步**不受**「分批发布时实时更新榜单」这个延迟结算开关影响(它改的是「这条成绩算不算数」,不是「录了多少」),不会出现「榜上还有分、成绩却已经撤了」; -- 两者都会留痕:**审计日志**(第 13 节)里的动作分别是 `result.void` 与 `result.reopen`,含操作人、时间与填写的原因。注意「变更历史」(第 12 节)记的是**发布 / 撤销发布**这类整组版本,单条作废 / 重新录入**不会**在那里新增一个版本节点。 +- 三个动作都会留痕:**审计日志**(第 13 节)里的动作分别是 `result.void`(作废)与 `result.reopen`(修改 / 重新录入),含操作人、时间与填写的原因。注意「变更历史」(第 12 节)记的是**发布 / 撤销发布**这类整组版本,单条的修改 / 作废 / 重新录入**不会**在那里新增一个版本节点。 -**手滑填错了怎么办:两条路** +**手滑填错了怎么办:三条路** -1. **只错了一条**(最常见):在那一行点 **「作废本条」** 填原因 → 这一条退出名次与积分、大屏立刻更新 → 同一行点 **「重新录入」**(此时按钮已变成它)填原因 → 退回草稿,该行可以编辑了 → 改对成绩 → 再点页头的 **「发布」**(该组还有别的已发布成绩时显示为「发布本批」,见 4.6)。全程只动这一条,同组别人的成绩一直留在榜上; -2. **整组都要改**(例如整组被判罚、名次口径整体调整):点页面上的 **「撤销发布」** → 修改 → 重新发布(见 4.7)。这条会把该组已产生的积分流水整体作废再重发,大屏会先回退一次。 +1. **只错了一条、要改对**(最常见):在那一行点 **成绩格**(或点 **「修改本条」**),确认框会写明后果,填「修改原因」→ 确认:这一条退回草稿、大屏按「没有这条成绩」重算 → **直接改格里的内容** → 再点页头的 **「发布」**(该组还有别的已发布成绩时显示为「发布本批」,见 4.6)。全程只动这一条,同组别人的成绩一直留在榜上; +2. **这一条根本不该算**(成绩无效、重复录入等):同一行点 **「作废本条」** 填原因 → 这一条不再计入名次与积分(成绩文字还留在格里,录入列变成红徽标「已作废」)。以后要让它重新算数,用同一行的 **「重新录入」** 退回草稿改完再发布。**只是填错了用第 1 条**——用「作废」会在榜上留下一条作废痕迹; +3. **整组都要改**(例如整组被判罚、名次口径整体调整):点页面上的 **「撤销发布」** → 修改 → 重新发布(见 4.7)。这条会把该组已产生的积分流水整体作废再重发,大屏会先回退一次。 -> 只错一条时**优先用第 1 条路**:撤销发布会让整组的分数先消失再回来,大屏在中间会闪一下;作废 / 重新录入只影响这一条。 +> 只错一条时**优先用第 1、2 条路**:撤销发布会让整组的分数先消失再回来,大屏在中间会闪一下;单条修改 / 作废只影响这一条。 -> 需要先「存草稿」再点这两个按钮:作废 / 退回会重算并整表刷新,未保存的编辑会被刷新覆盖,页面会拦下并提示。 +> 需要先「存草稿」再动这些入口:修改 / 作废 / 退回会重算并整表刷新,未保存的编辑会被刷新覆盖,页面会拦下并提示。 --- @@ -371,11 +378,15 @@ - 列:名次、单位、总分、金、银、铜、变化、明细; - 「变化」列显示名次升降(如 `↑2`、`↓1`); -- 点「展开」下钻该单位的积分明细,右侧面板显示合计与每一条得分来源(项目、组别、运动员、成绩、名次、积分),每一分都可溯源。 +- 点「展开」下钻该单位的积分明细,右侧面板显示合计与每一条得分来源(项目 / 组别、运动员、来源、成绩、名次、积分),每一分都可溯源; + - **「运动员」列**:团体项目、按队内平均算出的团体分、以及人工调整这几类没有单个运动员的明细,这一列显示**单位名**(单位的团体分就是该单位挣的);人工调整没有项目也没有运动员,显示 `—`; + - **「来源」列**:这一分是怎么来的——`成绩`(正常计分)、`破纪录加分`、`人工调整`,与导出的「来源」列用词一致; + - **被改过的成绩带一个「已修订 ×N」小角标**(N = 发布后被改过的次数),鼠标悬停在角标上会显示**最后一次修订的时间、原因与操作人**;没改过的行没有角标。 ### 8.2 单位奖牌榜 / 个人积分榜 - 单位奖牌榜显示金、银、铜与合计; +- **单位奖牌榜有自己的名次与顺序:金牌多的在前,其次比银牌、铜牌,最后按单位编码排**——它不看总分,所以「金牌多但总分低」的单位在这里排在前面,在单位总分榜上却靠后,这是口径不同,不是数据错了; - 个人积分榜显示运动员、单位、个人总分与奖牌数; - 单位奖牌榜那一列的「明细」展开的是**同一个单位积分明细面板**(见 8.1),不是奖牌专属明细;但这份榜单本身有专属的逐枚奖牌导出(见 8.3); - 个人积分榜**没有明细列**(不能点开下钻),但它的逐条得分来源也有专属导出(见 8.3)。 @@ -386,7 +397,7 @@ - **导出总明细**(单位总分榜卡片头):导出 `单位积分总明细.xlsx`——一张带「单位」列的总表,外加每个**有数据**的单位一张分表;没有积分的单位不出分表; - **导出奖牌明细**(单位奖牌榜卡片头):导出 `单位奖牌明细.xlsx`——第一张总表逐枚奖牌列出「单位 / 大项 / 小项 / 组别 / 名次 / 奖牌 / 运动员、队」,`奖牌` 列是 `金牌` / `银牌` / `铜牌`;其后每个**有奖牌**的单位一张分表(列同上、去掉「单位」,末尾一行小计 `金 X / 银 Y / 铜 Z`),没有奖牌的单位不出分表。口径与「单位奖牌榜」一致:同一个报名项在「个人+团体」这类双计通道下只记一枚奖牌,不会重复计数; - **导出个人明细**(个人积分榜卡片头):导出 `个人积分总明细.xlsx`——第一张总表逐条列出「单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源」;其后每个**有得分**的单位一张分表(按单位分,不按人分),没有得分流水的人不出现在表里。`得分` 是记到个人的原始分,**与「个人积分榜」逐个数字一致,且不乘榜单口径里的个人项目得分折算系数**(那个系数只作用于单位总分); -- **导出该单位明细**(右侧单位明细面板头部):先展开某个单位再点,导出 `单位积分明细.xlsx`(列:序号、大项、小项、组别、运动员、名次、成绩、来源、积分、备注,末行是「合计」)。单位奖牌榜展开的也是这个单位积分明细面板,所以想按单位逐条看时同样用它; +- **导出该单位明细**(右侧单位明细面板头部):先展开某个单位再点,导出 `单位积分明细.xlsx`(列:序号、大项、小项、组别、运动员、名次、成绩、来源、积分、备注,末行是「合计」)。**备注列会写下修订痕迹**:被改过的成绩写「已修订 ×N;最后一次 <时间> <原因>」,没改过的行留空——与屏幕上那个角标是同一句话,事后拿表格对账不会对不上。单位奖牌榜展开的也是这个单位积分明细面板,所以想按单位逐条看时同样用它; - 这些导出都是只读下载(访客也能用),不需要写权限;导出过程中按钮会变成「导出中…」并禁用,避免重复下载。 ### 8.4 推送明细到大屏 @@ -569,8 +580,13 @@ 2. 点「新增通道」逐条配置,同一「名次范围 + 名次来源」只能有一个通道; 3. 点「保存全部」整体替换该小项的通道配置; 4. 标记为「不计分」的小项,通道可以保存但不参与积分计算; -5. 「名次来源」选「按队内平均」时,把同一个单位(或个人)在该组别的成绩**取平均**后排名,并列按积分模板的并列口径处理(默认顺延);平均值分母取录入页选的「平均值口径」——`只算有效成绩`时是本单位有有效成绩的条数(一个人都没有效成绩的单位不参与团体排名、不得分),`全部报名人员都计入`时是本单位在该组别的报名条数(没成绩的按 0 计入)。报名表里 `status` 不是 `registered` 的报名项(弃赛 / 取消)既不算参与,也不进分母; -6. 每条通道有一个「计入单位总分」开关,默认打开。关掉后该通道的分只记在个人头上,不进单位总分——「个人+团体」项目的个人通道就靠它做到只记个人; +5. 「名次来源」选「按队内平均」时,把同一个单位(或个人)在该组别的成绩**汇总成一个值**后排名,并列按积分模板的并列口径处理(默认顺延)。汇总口径在该行的**「聚合口径」列**上选,两个按钮点一下即切换: + - **平均**(默认):团体成绩 = 该单位成员成绩的**平均值**。分母取录入页选的「平均值口径」——`只算有效成绩`时是本单位有有效成绩的条数(一个人都没有效成绩的单位不参与团体排名、不得分),`全部报名人员都计入`时是本单位在该组别的报名条数(没成绩的按 0 计入); + - **总和**:团体成绩 = 该单位成员成绩的**总和**(没成绩的人按 0 加与不加都一样)。 + - **各队人数相同时,两种口径排出来的名次完全相同**;人数不同时可能不同(这时按你们要的口径选,默认是「平均」)。改完点「保存全部」生效,该小项立即重算; + - 报名表里 `status` 不是 `registered` 的报名项(弃赛 / 取消)既不算参与,也不进分母; + - 不是「按队内平均」的通道这一列显示 `—`(对它没有意义); +6. 每条通道有一个「计入单位总分」开关。**新建通道时的默认值按小项类型给**:**普通个人项目 / 团体项目默认勾选**;**「个人 + 团体」项目的个人通道默认不勾**(这类项目的个人分本来就只记个人榜、不并入单位总分)。关掉后该通道的分只记在个人头上,不进单位总分——「个人+团体」项目的个人通道就靠它做到只记个人;**所以现场要留意自己那一行的实际勾选状态**; 7. 每条通道还有一个**「奖牌通道」**列,里面是两个开关(都默认打开): | 开关 | 默认 | 关掉之后 | @@ -804,7 +820,7 @@ 1. 顶部可筛选小项,以及「全部时间 / 近 24 小时 / 近 7 天」; 2. 左侧时间轴最新在上,节点显示动作(发布 / 撤销发布 / 编辑)、项目、操作人、时间与备注; 3. 点节点,右侧显示操作人、影响单位与成绩前后对比(diff); -4. 本页**不提供一键回滚**。纠错请到「成绩录入」:只错一条走「作废本条 → 重新录入 → 修改 → 重新发布」(见 4.10),整组要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。 +4. 本页**不提供一键回滚**。纠错请到「成绩录入」:只错一条走「点成绩格(或「修改本条」)→ 改 → 重新发布本组」(见 4.10),整组要改才走「撤销发布 → 修改 → 重新发布」(见 4.7)。 --- @@ -978,6 +994,9 @@ ### 17.3 明细覆盖层 - 管理端推送单位积分明细后,大屏右侧弹出半屏覆盖层,显示该单位每一分的来源; +- 覆盖层的表头是**项目 / 组别、运动员、名次、成绩、积分**五列:团体项目、按队内平均算出的团体分这类没有单个运动员的明细,「运动员」列显示**单位名**(不显示 `null`、也不留空格); +- **破纪录加分**那一类明细会在项目名后面多一个小标签 `破纪录`,一眼看出这几分不是名次分(人工调整的项目列本来就写着「人工调整」,不再重复打标签); +- **被改过的成绩**在那一行下面多一行小字,形如 `10:42 已修订(成绩录入笔误)`——大屏不能悬停、观众也离得远,所以时间只写几点几分、原因超过十几个字会截断(完整的时间、原因与操作人在管理端明细的角标悬停里看); - 默认 **60 秒后自动关闭**并恢复轮播; - 管理端「关闭大屏明细」可立即关闭。 @@ -1002,7 +1021,7 @@ 草稿不会上屏。需要点「发布」(或 `Alt+Enter`),发布成功后立即更新。 **Q:发布后发现有错怎么办?** -只错一条成绩:到「成绩录入」找到那一行,点「作废本条」→ 再点「重新录入」→ 改 → 重新发布,只动这一条(见 4.10)。 +只错一条成绩:到「成绩录入」找到那一行,**点那一格(或点「修改本条」)→ 填原因 → 确认 → 改对 → 重新发布本组**,只动这一条(见 4.10)。 整组要改:点「撤销发布」→ 修改 → 重新发布。撤销会作废该组积分流水,大屏回退;全程可在「变更历史」追溯。 **Q:并列名次怎么显示?** diff --git a/docs/升级说明.md b/docs/升级说明.md index 6fb1412..2dcac39 100644 --- a/docs/升级说明.md +++ b/docs/升级说明.md @@ -6,7 +6,94 @@ --- -## dev_0.5.0(当前版本) +## dev_0.5.1(当前版本) + +### 一句话 + +**两条数据迁移、三处计分口径变化、安装器换新,升级后建议重新导入一次新版规则包**:迁移 `0022` 把「小项已被删除、积分流水却还挂在榜上」的历史脏数据一次性作废(**只影响分数,不影响名次与奖牌,人工调整分不受影响**);迁移 `0023` 给计分通道加一列「聚合口径」(默认「平均」,不切就与升级前一样);**单位奖牌榜改为按奖牌数独立排名,可能与积分榜顺序不同**;安装界面全面重做(**安装逻辑不变**)。 + +### ① 数据迁移:两条(0022 / 0023) + +**`0022` —— 历史孤儿积分流水一次性作废**(现场可能少几分,那是升级前就该清掉的分) + +- **它清理什么**:升级前删掉一个「只按单位录名次、没有报名行」的小项时,它名下**已经产生**的积分流水会留在库里、并且没有被作废——分数继续算在单位总分里,但这个小项已经不存在,此后任何一次重算都再也碰不到这些流水(只能改库才能清)。 +- **只作废「成绩流水」与「破纪录加分」**:这两类流水一定挂着小项,空小项 = 小项已被删除。**人工调整分不受影响**——人工调整本来就可以不挂小项,是合法的,不会被误清。 +- **影响面**:**只影响分数**。这些流水本来就没有名次、也没有奖牌,所以**名次与奖牌一件不变**;作废后这些分在**单位总分与单位积分明细里一起消失**(不会出现「榜上无分、明细有分」)。它是**作废不是删除**:流水行与作废时间都留着,并注明是升级时清理的历史数据,事后查得到。 +- **怎么确认**:启动一次 Console,在运行日志或 `logs\operations\` 当天文件里能看到迁移记录(`已应用迁移 … name=0022_void_historical_orphan_ledger.sql`)。**现场确认删过「带分的小项」的机器上,相应单位的总分会少一点**——那是清掉了历史脏数据,不是这次升级算错了。 +- **想核对到条**:升级前先导出一次单位积分明细存底(「积分榜」页的「导出该单位明细」或「导出总明细」),升级后再导一次对比,就能看出少了哪几条。 + +**`0023` —— 计分通道新增一列「聚合口径」**(只加列、不动既有数据) + +- 老通道一律取默认**「平均」**(团体成绩 = 该单位成员成绩的平均值),**升级前后榜单一分不差**;想改成「总和」的,到「基础数据 → 计分通道」手工切(见《使用手册》10.4)。 + +### ② 三处口径变化(现场看到的数字 / 顺序可能不同) + +**1)「末位归零」:末位不再丢分** + +- 本版随包规则包里两个模板的「末位归零」都设成**关**:**参赛人数少于表里档位数时,最后一名按表拿分**(例:3 人参赛、模板有 6 档,第 3 名按表拿 6 分),不再被抹成 0。重新导入规则包会把库里这个开关一起写成「关」——**此前在模板页手工勾过它、或导入过把它勾上的规则包**的机器,这一步之后末位分数会变(这是定稿口径:规则里没配的名次分数就是 0 分,分只按表发)。 +- 另外,**开关打开时也只抹「真正落在最末那一档」的那一条**:并列档位从更靠前的名次起步、区间跨过末位的并列组**不再被整组抹 0**,按他们并列到的名次照表拿分(7 人里三人并列第 5、前六档表 → 每人按第 5 名拿分;升级前是 0 分)。 +- **名次与奖牌不受影响**:这一段改的只是「这一组拿多少分」。 + +**2)单位奖牌榜改为按奖牌数独立排名** + +- 顺序与名次:**金牌 → 银牌 → 铜牌 → 单位编码**,**不再跟着单位总分榜**。所以「金牌多、总分低」的单位在奖牌榜上排前面、在单位总分榜上排后面——**这是口径不同,不是数据错了**(升级前奖牌榜沿用总分榜顺序,会出现「金牌多的排在后面」)。 +- 三处同一个口径:管理端「积分榜 → 单位奖牌榜」、大屏的单位奖牌榜页、大屏底栏滚动的奖牌 Top3。 +- 「导出奖牌明细」(`单位奖牌明细.xlsx`)逐枚列出奖牌的总表不受影响;各单位**分表的先后仍按单位总分排**(得分高的在前),与奖牌榜的顺序不同,这不是错。 + +**3)同分并列段的排列按规则包的排序口径** + +- 总积分榜(及个人积分榜)里**总分相同**的单位那一段内,先后按规则包里的排序口径排——本赛会包的默认是 **总分 → 金 → 银 → 铜 → 单位编码**。 +- 重新导入规则包会把这条口径按规则包的值写回库里(本赛会包是 **总分 → 金 → 银 → 铜 → 单位编码**):**此前导入过排序口径写法不同的规则包**的机器,这一段里单位的先后可能变化——**变的是顺序,分数与奖牌都不变**,看到顺序变了不要当成故障。 +- **单位奖牌榜不受这条影响**:它固定按金 → 银 → 铜 → 单位编码(见上面第 2 条)。 + +### ③ 安装器换新(界面重做,安装逻辑不变) + +- 安装程序(`Scoreboard Console-<版本>-x64.exe`)**界面全面重做**:欢迎 / 目录 / 许可 / 进度 / 完成各页与按钮换成新素材,并**支持 4K 高 DPI**(系统缩放到 200% 时界面按缩放后的尺寸排布,字与按钮不会糊成一团)。 +- **安装逻辑一个字没改**:装到哪、装什么、桌面快捷方式(「赛事积分榜」)、卸载方式、覆盖安装时先静默跑一遍旧卸载器、数据与配置在 `%LOCALAPPDATA%\ScoreboardConsole\`(**覆盖安装不会丢**)——全部与升级前一致。 +- 默认安装模式是**「为所有用户安装」**(装到 Program Files,安装时提一次权);**这台机器上次装过时,向导沿用上次的装法**,静默安装也可以用 `/allusers` / `/currentuser` 指定。这一趟**仍然自动放行防火墙入站 19540**(在防火墙里能看到规则 `Scoreboard Console (TCP 19540)`),局域网大屏不用再手工放行;选「仅为我安装」的机器要手工放行(命令见《部署指南》7.5)。 + +### ④ 升级后建议:把新版规则包重新导入一次 + +**做法**:管理端「导出 → 规则包 → 选择 YAML → **预览差异** → 确认导入并全量重算」,导入随本次发布的新版规则包。 + +**为什么建议做**:旧包缺少本次修正的通道配置(几个双计项目的团体通道口径、纯团体项目该走「按成绩自动排名」还是「人工名次」等),**不重新导入就不会生效**。 + +⚠️ **动手前先看清这个前提**:**导入会按规则包重写各小项的计分通道配置**——规则包覆盖到的小项,库里现有的通道会被整包替换。所以: + +- **建议先导入、再按需要手工微调个别项目**;顺序反过来(先微调再导入),手工改动会被这次导入覆盖掉; +- 导入前先看「预览差异」里列出的通道增删改,确认没有把现场专门配过的项目改掉;发现被改,导入后到「基础数据 → 计分通道」改回来即可(保存后该小项会重算); +- 导入会**全量重算积分并刷新榜单**,尽量安排在**没有正在进行的项目**时做; +- 导入后正是上面那三处口径变化的生效点,请对着 ② 逐条看一遍榜单。 + +### ⑤ 升级时可能遇到的拦截(这两处以前会静默通过) + +- **导入规则包时**:如果这份规则包存在**配置冲突**——典型情形是某个小项的**名次是靠人工录入的**(例如球类、拔河这类由裁判判名次的项目),规则包却给它配了**「按成绩自动排名」**(或「按队内平均」)的通道——导入会被**整包拒收**,一个字节都不写,并直接说明**是哪个小项、为什么不行、怎么改**(给这个小项单独写一段人工名次的通道配置,或调整规则包的匹配条件别套到它身上)。升级前这种情况是「先把通道换掉、紧接着重算时才报错」,名次表会停在半空(分数还在、奖牌却清零),自愈与重试导入都会因为同一个小项再失败一次——**现在不会留下这种坏状态**。 +- **保存积分模板时**:如果**分值表里配的名次超过了「录取名次」**(例如分值表配到第 7 档、录取名次还留着 6),保存会被**拒绝**并说明怎么改:要么把录取名次改成 7、要么改成 0(0 = 不按名次封顶),要么删掉超出录取名次的那几档(例子里的第 7 档)。升级前这种配置能安静存下来,但第 7 档及以后在榜上恒为 0 分,界面上看不出任何异常——**现在会在保存这一步就说清楚**。 + +### ⑥ 升级后 5 分钟核对 + +1. 启动一次 Console,看运行日志 / `logs\operations\` 当天文件:应能看到 `0022` 与 `0023` 两条迁移记录,没有别的报错; +2. 刷新管理端「积分榜」:**单位总分榜、个人积分榜的数字与升级前一致**(除了 `0022` 清掉的历史脏数据);**单位奖牌榜的先后可能变了**(现在按金 → 银 → 铜排); +3. **看到「同分单位的先后变了」不要当故障**:重导规则包会把排序口径按规则包的值写回(本赛会包是 总分 → 金 → 银 → 铜 → 单位编码),**变的是顺序,分数与奖牌一件不变**(见 ② 第 3 条); +4. 打开「基础数据 → 计分通道」,选中一个双计项目(例如团体引体向上),确认「聚合口径」一列是**「平均」**——不切换就与升级前口径一致; +5. 打开「成绩录入」页:已发布的行右侧应**并排**「修改本条」与「作废本条」两个按钮;点一下成绩格会弹确认框(**点「取消」什么都不做**); +6. 按 ④ 导入新版规则包,导入后核对该项目的计分通道与榜单; +7. 用《上线检查清单》过一遍。 + +### ⑦ 详细说明在哪 + +| 想了解 | 看 | +| --- | --- | +| 已发布成绩怎么改(修改 / 作废的区别) | 《使用手册》4.10;《常见问题》Q98 | +| 单位明细里的运动员 / 来源 / 已修订 | 《使用手册》8.1、8.3、17.3 | +| 团体分的「平均 / 总和」口径 | 《使用手册》10.4;《常见问题》Q15 | +| 单位奖牌榜为什么与积分榜顺序不同 | 《使用手册》8.2 | +| 导入被拒 / 保存模板被拒是什么原因 | 本文件 ⑤ | +| 迁移与安装 | 本文件 ① ③;《部署指南》3.1、7.5 | + +--- + +## dev_0.5.0 ### 一句话 diff --git a/docs/常见问题.md b/docs/常见问题.md index 225e3af..5adfab8 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -34,7 +34,7 @@ 草稿不上屏。需要「发布」(或 `Alt+Enter`),发布后 1 秒内大屏更新。 **Q8:发布错了怎么改?** -撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。**只错了一条成绩时不必撤销整组**,用那一行的「作废本条 → 重新录入」即可,见 Q98。 +撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。**只错了一条成绩时不必撤销整组**,点那一格(或那一行的「修改本条」)退回草稿改完重发即可,见 Q98。 **Q9:为什么「发布」是灰的?** 常见原因:未登录、服务端断开、示例数据模式,或存在**会阻止发布**的校验错误(秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。**没录成绩不算错误、不挡发布**(见 Q77)。成绩格的报错会把**那一格的实际内容**打出来并对症给改法(见 Q75、Q76)。 @@ -58,7 +58,7 @@ - **个人通道**:按个人成绩排名、套个人积分模板给分,分只记在个人积分榜; - **团体通道**:按团体成绩排名、套团体积分模板给分,计入单位总分。 -个人通道的「计入单位总分」关掉了才不进单位总分:按规则包里的「个人+团体」模板生成的通道,个人通道默认就是不勾的(手工新建通道时默认勾选,以页面上实际的勾选状态为准)。到「基础数据 → 计分通道」选中该小项就能看到每行通道的勾选状态,关掉的行在「大项 / 小项」的通道摘要里带「不计入单位总分」徽标。 +个人通道的「计入单位总分」关掉了才不进单位总分:按规则包里的「个人+团体」模板生成的通道,个人通道默认就是不勾的。**手工新建通道时的默认值也按小项类型给**:普通个人项目 / 团体项目默认勾选;**「个人 + 团体」项目的个人通道默认不勾**——所以**以页面上实际的勾选状态为准**。到「基础数据 → 计分通道」选中该小项就能看到每行通道的勾选状态,关掉的行在「大项 / 小项」的通道摘要里带「不计入单位总分」徽标。 想让个人分也折算进单位总分,把个人通道那一行勾上并「保存全部」,然后对该组成绩**撤销发布 → 重新发布**——积分流水是发布时按当时的通道配置生成的,不重新发布不会变。规则包对个别小项写了例外配置时以例外为准。 @@ -69,7 +69,8 @@ - 平均值的分母有两种口径,选中该小项后在「成绩录入」页的工具栏下方用**「平均值口径」**单选切换,点选即保存(小项级设置,该小项的组别共用,不用重新点保存): - `只算有效成绩`(默认):分母是有有效成绩的人数; - `全部报名人员都计入`:分母是该单位在本组别的报名条数,没成绩的按 0 计入。 -- 改完口径、要让已发布的成绩按新口径重算,需要对该组**撤销发布 → 重新发布**(草稿不受影响,改口径本身在点选时就已保存);发布之前可以先看右侧「实时积分预览」,它已经按新口径显示各单位的分数变化。 +- **团体成绩怎么汇总**:到「基础数据 → 计分通道」选中该小项,那条团体通道的**「聚合口径」**列可选 **`平均`**(默认,团体成绩 = 该单位成员成绩的平均值)或 **`总和`**(团体成绩 = 该单位成员成绩的总和)。点一下即选中,点「保存全部」生效、该小项立即重算。**各队人数相同时两种口径排出来的名次完全相同**,人数不同时可能不同;上面那条「平均值口径」只对 `平均` 有意义——换成 `总和` 后,它只决定「一个人都没有成绩的单位算不算 0 分参与排名」。 +- 改完口径、要让已发布的成绩按新口径重算,需要对该组**撤销发布 → 重新发布**(草稿不受影响,改口径本身在点选时就已保存);发布之前可以先看右侧「实时积分预览」,它已经按新口径显示各单位的分数变化。改「聚合口径」不用这一步——它保存时就重算。 - 报名表里 `status` 不是 `registered` 的报名项(弃赛 / 取消资格)既不算分子也不算分母。 **Q16:某个单位一个人都没有效成绩,团体项目怎么算?** @@ -410,6 +411,8 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 单位奖牌榜那一列的「明细」展开的仍是**同一个单位积分明细面板**,所以想按单位看时也可以在那上面点「导出该单位明细」;但单位奖牌榜本身另有专属的逐枚奖牌导出(卡片头「导出奖牌明细」,见 Q71),个人积分榜也有专属的逐条得分导出(卡片头「导出个人明细」,见 Q70、Q72)。另外「导出 → 结果导出」里的「单位积分明细」也能先选一个单位再导出。这些导出都是只读下载,访客也能用。 +> 明细面板与这份导出里的列:**运动员**(团体项目、按队内平均的团体分、人工调整这几类显示单位名或 `—`)、**来源**(`成绩` / `破纪录加分` / `人工调整`),以及**被改过的成绩**:面板上带「已修订 ×N」角标(悬停看最后一次修订的时间、原因与操作人),导出的**备注列**写「已修订 ×N;最后一次 <时间> <原因>」。没改过的行没有角标、备注留空。 + **Q70:个人积分榜能导出明细吗?** 能。个人积分榜的**卡片头**有「导出个人明细」按钮,导出 `个人积分总明细.xlsx`:第一张总表逐条列出「单位 / 运动员 / 大项 / 小项 / 组别 / 名次 / 成绩 / 得分 / 来源」,其后每个**有得分**的单位一张分表(按单位分,不按人分;没有得分流水的人不出现在表里)。`得分` 是记到个人的原始分,与「个人积分榜」逐个数字一致,**不受榜单口径里的个人项目得分折算系数影响**(那个系数只作用于单位总分),详见 Q72。 @@ -635,20 +638,23 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点 先看是**只错一条**还是**整组都要改**——两条路的影响面完全不同: -**① 只错一条(最常见)**:到「成绩录入」找到那一行,用最右端「操作」列的按钮: +**① 只错一条(最常见)**:到「成绩录入」找到那一行,**点成绩格(或点状态格),也可以点最右端「操作」列的「修改本条」**: -1. 点 **「作废本条」**,填 **「作废原因」**(必填,留空点了不提交)→ 确认。这一条立刻退出名次与积分,同组其它人重新排名,**大屏立即更新**; -2. 同一行的按钮这时变成 **「重新录入」**,点它、填 **「退回原因」**(必填)→ 这一条退回草稿,变成可以编辑的行; +1. 点下去会弹出确认框,写明「这一条会退回草稿、榜单先把它退下来、改完需重新发布本组」,并要你填 **「修改原因」**(必填,留空点了不提交)——**点「取消」则什么都不做**(成绩、榜单、大屏都不动); +2. 确认后这一条退回草稿,名次与积分先按「没有这条成绩」重算,**大屏立即更新**;原来填的成绩还在格里,直接改就行; 3. 把成绩改对,再点页头的 **「发布」**(该组还有别的已发布成绩时显示为「发布本批」,见 Q77)。同组别人的成绩一直在榜上,不会被翻下来。 -**② 整组都要改**(例如整组被判罚、名次口径整体调整):点「撤销发布」→ 修改 → 重新发布(见 Q8)。它会把该组已产生的积分流水整体作废再重发,大屏先回退一次。 +**② 这一条根本不该算**(成绩无效、重复录入等):同一行点 **「作废本条」**,填 **「作废原因」**(必填)→ 确认。这一条不再计入名次与积分,同组其它人重新排名、**大屏立即更新**;成绩文字还留在格里,录入列变成红徽标「已作废」,状态格变成「退赛」。以后要让它重新算数,用同一行的 **「重新录入」** 退回草稿改完再发布。**只是填错了要用 ① 的「修改」,不要用「作废」**——「作废」会在榜上留下一条作废痕迹。 -两个按钮的细节: +**③ 整组都要改**(例如整组被判罚、名次口径整体调整):点「撤销发布」→ 修改 → 重新发布(见 Q8)。它会把该组已产生的积分流水整体作废再重发,大屏先回退一次。 -- **已发布的行是锁着的**(成绩 / 名次 / 状态 / 备注都改不了)——这是有意的:已发布就意味着已经上屏、已经计分,允许就地改会变成「榜单上是旧数、表格里是新数」;要改必须先明确地把它退回来,所以给了「作废本条 / 重新录入」这一对按钮,而不是直接解锁; -- 作废与退回都**立即重算并推大屏**,**不受「分批发布时实时更新榜单」这个延迟结算开关影响**(它们改的是「这条成绩算不算数」,不是「录了多少」); -- 两者都会**留痕**:审计日志(见使用手册第 13 节)里的动作分别是 `result.void` 与 `result.reopen`,含操作人、时间与填写的原因。「变更历史」记的是**发布 / 撤销发布**这类整组版本,单条作废 / 退回不会在那里新增一个节点; -- 有个前提:点之前先把未保存的编辑「存草稿」——作废 / 退回会重算并整表刷新,页面会拦下并提示。 +三个入口的细节: + +- **已发布的行仍然锁着**(名次、备注改不了),但**成绩格与状态格点得动**:点一下只弹出与「修改本条」同一个确认框,不确认就什么都不会发生——这是有意的,已发布就意味着已经上屏、已经计分,**绝不能「点一下成绩就悄悄从榜上退下去」**;要改必须先明确地把它退回来,所以给了「修改本条 / 作废本条」这一对按钮,而不是直接解锁; +- **「修改」与「作废」的区别**:**修改**是「这一条填错了,退回草稿改一改再发」,不会把这一条标成「已作废」;**作废**是「这一条不算了」,成绩留在表里但不再计分。改错字用「修改」,别用「作废」; +- 修改 / 作废 / 退回都**立即重算并推大屏**,**不受「分批发布时实时更新榜单」这个延迟结算开关影响**(它们改的是「这条成绩算不算数」,不是「录了多少」); +- 三者都会**留痕**:审计日志(见使用手册第 13 节)里的动作分别是 `result.reopen`(修改 / 重新录入)与 `result.void`(作废),含操作人、时间与填写的原因。「变更历史」记的是**发布 / 撤销发布**这类整组版本,单条的修改 / 作废 / 退回不会在那里新增一个节点; +- 有个前提:点之前先把未保存的编辑「存草稿」——修改 / 作废 / 退回会重算并整表刷新,页面会拦下并提示。 **Q99:误批准了破纪录怎么办?** diff --git a/server/internal/domain/aggregate_mode_test.go b/server/internal/domain/aggregate_mode_test.go new file mode 100644 index 0000000..f113b12 --- /dev/null +++ b/server/internal/domain/aggregate_mode_test.go @@ -0,0 +1,232 @@ +package domain + +import ( + "reflect" + "testing" +) + +// 聚合口径(通道的 aggregate_mode:avg 按成员平均 / sum 按成员总和)的引擎口径。 +// +// 现场要在「平均」与「总和」之间临时定口径(团体引体向上这类双计项目领导还没拍板), +// 所以这里钉住三件事:缺省必须是 avg(升级前后榜单一分不差)、人数相同时两种口径排名一致、 +// 人数不同时两者可能不同(现场不能默认它们等价)。 + +// aggScore 用指定汇总口径跑一次团体聚合通道,返回「单位 → 平均/总和值、名次、积分」。 +func aggScore(t *testing.T, ev Event, mode AggregateMode, entries []Entry, results []Result) EventScore { + t.Helper() + ch := teamAggregateChannel(2) + ch.AggregateMode = mode + return mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{ch}, + Profiles: map[int64]ScoringProfile{2: teamTop6()}, + Entries: entries, Results: results, + }) +} + +// TestAggregateModeEqualRosterSameRanking:各队人数相同时 sum 与 avg 的排名与积分完全一致。 +// 这是现场选口径的依据——人数整齐时选哪种都不影响榜单,人数不齐时才会分叉。 +func TestAggregateModeEqualRosterSameRanking(t *testing.T) { + // 三队各 2 人,compare=desc(越大越好)。总和 = 平均 × 2,是同一个单调变换。 + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 18}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 20}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 16}, + {entryID: 4, athleteID: 4, teamID: 1002, value: 14}, + {entryID: 5, athleteID: 5, teamID: 1003, value: 12}, + {entryID: 6, athleteID: 6, teamID: 1003, value: 10}, + }) + ev := teamEvent(1) + ev.Compare = CompareDesc + + avg := aggScore(t, ev, AggregateAvg, entries, results) + sum := aggScore(t, ev, AggregateSum, entries, results) + + // 名次与积分逐队一致(录取前六名,三队都有分);汇总值本身当然不同(19 平均 / 38 总和)。 + avgOutcome := aggregateByTeam(t, avg) + sumOutcome := aggregateByTeam(t, sum) + for _, teamID := range []int64{1001, 1002, 1003} { + if avgOutcome[teamID].rank != sumOutcome[teamID].rank || !nearlyEqual(avgOutcome[teamID].points, sumOutcome[teamID].points) { + t.Fatalf("人数相同时两种口径的名次/积分应一致:单位 %d avg=%+v sum=%+v", + teamID, avgOutcome[teamID], sumOutcome[teamID]) + } + } + if !reflect.DeepEqual(pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger)) { + t.Fatalf("人数相同时两种口径的流水应一致:avg=%v sum=%v", + pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger)) + } + // 顺带钉住名次本身:1001 平均 19 / 总和 38 → 第一。 + got := aggregateByTeam(t, sum) + if got[1001].rank != 1 || got[1002].rank != 2 || got[1003].rank != 3 { + t.Fatalf("sum 口径名次 = %+v,期望 1001/1002/1003 依次第 1/2/3 名", got) + } +} + +// TestAggregateModeRosterDiffersFlipsRanking:各队人数不同时两种口径的排名会不同。 +// 1001 队 2 人(10 + 1)、1002 队 1 人(9):平均看 1002 强,总和看 1001 强。 +func TestAggregateModeRosterDiffersFlipsRanking(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 10}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 1}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 9}, + }) + ev := teamEvent(1) + ev.Compare = CompareDesc + + avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results)) + sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results)) + + // 平均:1001 = 5.5 第二、1002 = 9 第一。 + if !nearlyEqual(avg[1001].value, 5.5) || avg[1001].rank != 2 || avg[1001].points != 10 { + t.Errorf("avg:1001 应 5.5 第 2 名 10 分,实际 %+v", avg[1001]) + } + if !nearlyEqual(avg[1002].value, 9) || avg[1002].rank != 1 || avg[1002].points != 15 { + t.Errorf("avg:1002 应 9 第 1 名 15 分,实际 %+v", avg[1002]) + } + // 总和:1001 = 11 第一、1002 = 9 第二。 + if !nearlyEqual(sum[1001].value, 11) || sum[1001].rank != 1 || sum[1001].points != 15 { + t.Errorf("sum:1001 应 11 第 1 名 15 分,实际 %+v", sum[1001]) + } + if !nearlyEqual(sum[1002].value, 9) || sum[1002].rank != 2 || sum[1002].points != 10 { + t.Errorf("sum:1002 应 9 第 2 名 10 分,实际 %+v", sum[1002]) + } + if avg[1001].rank == sum[1001].rank { + t.Fatalf("人数不同时两种口径的名次不应相同:avg=%+v sum=%+v", avg, sum) + } +} + +// TestAggregateModeDefaultsToAverage:缺省(不带 aggregateMode / 老库老客户端)就是 avg, +// 结果与显式写 avg 逐字段一致——现有数据与现有用例的行为一字不变。 +func TestAggregateModeDefaultsToAverage(t *testing.T) { + if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg { + t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got) + } + // 认不出的取值也退回 avg,不是退回 sum(库里有 CHECK,但内存里的对象仍要兜住)。 + for _, bogus := range []AggregateMode{"", "average", "AVG", "SUM", "biggest"} { + if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg { + t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got) + } + } + + // 与 TestAggregateAvgMode 同一个现场(compare=asc,越小越好):1001 一人有成绩 10、一人没成绩; + // 1002 两人各 4 / 6。valid 口径下 1002 平均 5 第一(15 分)、1001 平均 10 第二(10 分) + // ——这是升级前的既有结果。 + legacy := []struct { + name string + mode AvgMode + build func() ([]Entry, []Result) + want map[int64]aggregateOutcome + }{ + { + name: "valid", + mode: AvgModeValid, + build: func() ([]Entry, []Result) { + return fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 10}, + {entryID: 2, athleteID: 2, teamID: 1001, noValue: true}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 4}, + {entryID: 4, athleteID: 4, teamID: 1002, value: 6}, + }) + }, + want: map[int64]aggregateOutcome{ + 1002: {value: 5, points: 15, rank: 1}, + 1001: {value: 10, points: 10, rank: 2}, + }, + }, + { + name: "all", + mode: AvgModeAll, + build: func() ([]Entry, []Result) { + return fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 10}, + {entryID: 2, athleteID: 2, teamID: 1001, noValue: true}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 4}, + {entryID: 4, athleteID: 4, teamID: 1002, value: 6}, + }) + }, + want: map[int64]aggregateOutcome{ + 1001: {value: 5, points: 15, rank: 1}, + 1002: {value: 5, points: 15, rank: 1}, + }, + }, + } + for _, tc := range legacy { + t.Run("既有结果不变/"+tc.name, func(t *testing.T) { + entries, results := tc.build() + ev := teamEvent(1) + ev.AvgMode = tc.mode + + // defaultScore:通道不带 aggregateMode(老库数据 / 老客户端保存的通道)。 + defaultScore := aggScore(t, ev, "", entries, results) + explicitAvg := aggScore(t, ev, AggregateAvg, entries, results) + + // 「缺省」与「显式 avg」必须逐字段一致(连流水顺序都一致)。 + if !reflect.DeepEqual(defaultScore.Ledger, explicitAvg.Ledger) { + t.Errorf("缺省口径的流水与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ledger, explicitAvg.Ledger) + } + if !reflect.DeepEqual(defaultScore.Ranked, explicitAvg.Ranked) { + t.Errorf("缺省口径的名次与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ranked, explicitAvg.Ranked) + } + + got := aggregateByTeam(t, defaultScore) + for teamID, want := range tc.want { + have := got[teamID] + if !nearlyEqual(have.value, want.value) || have.rank != want.rank || !nearlyEqual(have.points, want.points) { + t.Errorf("单位 %d = %+v,期望 %+v", teamID, have, want) + } + } + if got := pointsByTeam(defaultScore.Ledger); !reflect.DeepEqual(got, pointsByTeam(explicitAvg.Ledger)) { + t.Errorf("缺省口径的单位总分与显式 avg 不一致:%v / %v", got, pointsByTeam(explicitAvg.Ledger)) + } + }) + } +} + +// TestAggregateModeSumWithAvgMode:sum 只累加有效成绩,avg_mode 在求和口径下只剩 +// 「一个有效成绩都没有的单位算不算 0 分参与排名」这一条作用(valid 不参与 / all 参与)。 +func TestAggregateModeSumWithAvgMode(t *testing.T) { + build := func() ([]Entry, []Result) { + return fixture([]participant{ + {entryID: 1, athleteID: 1, teamID: 1001, value: 10}, + {entryID: 2, athleteID: 2, teamID: 1001, value: 1}, + {entryID: 3, athleteID: 3, teamID: 1002, value: 9}, + // 1003 全队一条有效成绩都没有:valid 不参与、all 按 0 参与(与平均口径同规则)。 + {entryID: 4, athleteID: 4, teamID: 1003, noValue: true}, + }) + } + + t.Run("valid:没成绩的单位不成桶", func(t *testing.T) { + entries, results := build() + ev := teamEvent(1) + ev.Compare = CompareDesc + ev.AvgMode = AvgModeValid + got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results)) + if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 { + t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001]) + } + if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 { + t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002]) + } + if got[1003].rank != 0 || got[1003].points != 0 { + t.Errorf("1003 全队没成绩,不应参与排名,实际 %+v", got[1003]) + } + }) + + t.Run("all:没成绩的单位按 0 参与、排在最后", func(t *testing.T) { + entries, results := build() + ev := teamEvent(1) + ev.Compare = CompareDesc + ev.AvgMode = AvgModeAll + got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results)) + // 求和:1001 = 11 第一;1002 = 9 第二;1003 = 0 第三(加 0 不改变其它队的和)。 + if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 { + t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001]) + } + if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 { + t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002]) + } + if !nearlyEqual(got[1003].value, 0) || got[1003].rank != 3 { + t.Errorf("1003 应总和 0 第 3 名,实际 %+v", got[1003]) + } + }) +} diff --git a/server/internal/domain/channel_ranks_test.go b/server/internal/domain/channel_ranks_test.go index 1f3d824..151e3f0 100644 --- a/server/internal/domain/channel_ranks_test.go +++ b/server/internal/domain/channel_ranks_test.go @@ -44,12 +44,30 @@ func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput { } // writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks: -// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank。 +// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank, +// 且要给 manual 通道的「录入位」让路(工单 A-1 / A-2,与 httpapi.scoring.go 同口径): +// 配了 [athlete,manual] 时整项不写回,配了 [team,manual] 时团体报名项(AthleteID=0)不写回。 +// 这里少一条判断,模拟出来的「第二次发布」就会跟真实系统不是一回事。 func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) { + athleteManual, teamManual := false, false + for _, ch := range in.Channels { + if ch.Basis != BasisManual { + continue + } + switch ch.Scope { + case ScopeAthlete: + athleteManual = true + case ScopeTeam: + teamManual = true + } + } for _, rr := range ranked { if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 { continue } + if athleteManual || (teamManual && rr.AthleteID == 0) { + continue + } for i := range in.Results { if in.Results[i].ID == rr.ResultID { in.Results[i].Rank = rr.Rank @@ -58,6 +76,93 @@ func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) { } } +// TestTeamEntryDualChannelPublishTwiceIsIdempotent 是工单 A-1 的引擎侧回归: +// team 类型小项 + 3 条团体报名项(athlete_id 为空)+ 双通道 [athlete/result, team/manual]。 +// 团体报名项的 results.rank 是「单位名次」的录入位,个人通道的自动名次不许写进去, +// 否则第二次发布团体手工通道会把个人名次当成团体名次再发一遍分(10/8/6 → 25/18/14)。 +func TestTeamEntryDualChannelPublishTwiceIsIdempotent(t *testing.T) { + entries, results := fixture([]participant{ + {entryID: 1, teamID: 1001, name: "甲队", value: 90}, + {entryID: 2, teamID: 1002, name: "乙队", value: 80}, + {entryID: 3, teamID: 1003, name: "丙队", value: 70}, + }) + ev := teamEvent(1) + ev.Compare = CompareDesc + in := EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{ + {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1}, + teamManualChannel(2), + }, + Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()}, + Entries: entries, Results: results, + } + + first := mustScore(t, in) + writeBackComputedRanks(in, first.Ranked) + second := mustScore(t, in) + + if !reflect.DeepEqual(first, second) { + t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second) + } + for _, r := range in.Results { + if r.Rank != 0 { + t.Fatalf("团体报名项的 results.rank 被自动名次写回,得到 %+v", r) + } + } + // 团体手工通道没有录入名次:两次发布都只有个人通道的 10/8/6。 + got := pointsByTeam(second.Ledger) + for _, want := range []struct { + teamID int64 + points float64 + }{{1001, 10}, {1002, 8}, {1003, 6}} { + if !nearlyEqual(got[want.teamID], want.points) { + t.Fatalf("单位 %d = %v,期望 %v(团体名次未录,不该多出团体分):%v", + want.teamID, got[want.teamID], want.points, got) + } + } + if len(second.Ranked) != 3 { + t.Fatalf("名次明细 = %d 条,期望只有个人通道的 3 条:%+v", len(second.Ranked), second.Ranked) + } +} + +// TestAthleteManualRankNotOverwritten 是工单 A-2 的引擎侧回归: +// [athlete,result] 与 [athlete,manual] 共存时,results.rank 是人工录入的个人名次, +// 自动名次不许覆盖它;反复「发布 → 写回」必须得到同一份结果。 +func TestAthleteManualRankNotOverwritten(t *testing.T) { + // 成绩 30 / 29(compare=asc:数值小的靠前 → 自动名次本该是 乙1 第 1、甲1 第 2); + // 人工名次反着填(甲1 第 1、乙1 第 2)。 + entries, results := fixture([]participant{ + {entryID: 1, athleteID: 101, teamID: 1001, name: "甲", value: 30, rank: 1}, + {entryID: 2, athleteID: 102, teamID: 1002, name: "乙", value: 29, rank: 2}, + }) + in := EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{ + athleteResultChannel(1), + {ID: 2, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: 1}, + }, + Profiles: map[int64]ScoringProfile{1: individualTop6()}, + Entries: entries, Results: results, + } + + first := mustScore(t, in) + writeBackComputedRanks(in, first.Ranked) + second := mustScore(t, in) + + if !reflect.DeepEqual(first, second) { + t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second) + } + if in.Results[0].Rank != 1 || in.Results[1].Rank != 2 { + t.Fatalf("人工名次被自动名次覆盖:甲=%d、乙=%d,期望 1 / 2", in.Results[0].Rank, in.Results[1].Rank) + } + // 自动通道 8/10 + 人工通道 10/8:两队各 18 分。 + got := pointsByTeam(second.Ledger) + if !nearlyEqual(got[1001], 18) || !nearlyEqual(got[1002], 18) { + t.Fatalf("两队应各 18 分(自动 + 人工),得到 %v", got) + } +} + // TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归: // 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。 func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) { diff --git a/server/internal/domain/last_place_zero_test.go b/server/internal/domain/last_place_zero_test.go new file mode 100644 index 0000000..aed5a8f --- /dev/null +++ b/server/internal/domain/last_place_zero_test.go @@ -0,0 +1,359 @@ +package domain + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "testing" +) + +// 任务 F:修正「末位归零」的语义。 +// +// 修复前的判据是「本组占用的名次区间是否包含末位名次」: +// +// startRank <= lastRank && lastRank <= startRank+size-1 +// +// 并列组只要跨过末位就整组 0 分,于是 7 人里三人并列第 5(占 5/6/7)时, +// 第 5 名的 2 分与第 6 名的 1 分被一起抹掉(审查报告 01 的 P1)。 +// +// 修复后的语义:**末位那一档(名次 == lastPlace)不得分**,并列组按起始名次取分, +// 所以只有起始名次正好落在末位档的组才归零——那种组必然只有一个人(startRank == lastPlace +// 蕴含 size == 1,因为 lastPlace 是所有组占用名次的最大值)。区间跨过末位的并列组不归零。 +// +// 修复前后实测对照(个人成绩通道,模板 10/8/6/3/2/1,lastPlaceZero=true): +// +// ① 7 人三人并列第 5(占 5/6/7):修前 [10 8 6 3 0 0 0] → 修后 [10 8 6 3 2 2 2] +// ② 5 人独占第 5 名: 修前 [10 8 6 3 0] → 修后 [10 8 6 3 0](真末位,继续归零) +// ③ 6 人两人并列第 5(占 5/6): 修前 [10 8 6 3 0 0] → 修后 [10 8 6 3 2 2] +// +// 名次(Rank)不受本修复影响:修复只改「这一组拿多少分」,不改谁排第几。 + +// scoreValueChannel 用个人成绩通道(basis=result)跑一次计分,返回 entryID 1..len(values) +// 逐条的积分与展示名次。 +func scoreValueChannel(t *testing.T, profile ScoringProfile, values ...float64) ([]float64, []int) { + t.Helper() + entries, results := fixture(lineupValues(values...)) + es := mustScore(t, EventScoreInput{ + Event: individualEvent(1), Category: testCategory(), + Channels: []ScoreChannel{athleteResultChannel(9)}, + Profiles: map[int64]ScoringProfile{9: profile}, + Entries: entries, Results: results, + }) + pts := pointsByEntry(es.Ledger) + ranks := rankByEntry(es.Ranked) + out := make([]float64, len(values)) + outRanks := make([]int, len(values)) + for i := range values { + out[i] = pts[int64(i+1)] + outRanks[i] = ranks[int64(i+1)] + } + return out, outRanks +} + +// scoreManualTeamChannel 用团体手工名次通道(basis=manual + scope=team)跑一次计分, +// 返回按录入顺序(单位 1001、1002 …)的积分。 +func scoreManualTeamChannel(t *testing.T, profile ScoringProfile, ranks []int) []float64 { + t.Helper() + teamRanks := make([]TeamManualRank, 0, len(ranks)) + for i, rk := range ranks { + teamRanks = append(teamRanks, TeamManualRank{TeamID: int64(1001 + i), Rank: rk}) + } + es := mustScore(t, EventScoreInput{ + Event: teamEvent(1), Category: testCategory(), + Channels: []ScoreChannel{teamManualChannel(9)}, + Profiles: map[int64]ScoringProfile{9: profile}, + TeamRanks: teamRanks, + }) + pts := pointsByTeam(es.Ledger) + out := make([]float64, len(ranks)) + for i := range ranks { + out[i] = pts[int64(1001+i)] + } + return out +} + +// scoreAggregateChannel 用团体聚合通道(basis=aggregate)跑一次计分,每个单位一条报名项, +// 返回按单位 1001、1002 … 顺序的积分。 +func scoreAggregateChannel(t *testing.T, profile ScoringProfile, mode AvgMode, values ...float64) []float64 { + t.Helper() + ps := make([]participant, 0, len(values)) + for i, v := range values { + ps = append(ps, participant{ + entryID: int64(i + 1), athleteID: int64(100 + i + 1), teamID: int64(1001 + i), + name: "选手" + string(rune('A'+i)), value: v, + }) + } + entries, results := fixture(ps) + ev := teamEvent(1) + ev.AvgMode = mode + es := mustScore(t, EventScoreInput{ + Event: ev, Category: testCategory(), + Channels: []ScoreChannel{teamAggregateChannel(9)}, + Profiles: map[int64]ScoringProfile{9: profile}, + Entries: entries, Results: results, + }) + pts := pointsByTeam(es.Ledger) + out := make([]float64, len(values)) + for i := range values { + out[i] = pts[int64(1001+i)] + } + return out +} + +// TestLastPlaceZeroOnlyZeroesLastPlaceTier 覆盖工单任务 F 要求的三种形态。 +func TestLastPlaceZeroOnlyZeroesLastPlaceTier(t *testing.T) { + profile := individualTop6() + profile.LastPlaceZero = true + + cases := []struct { + name string + values []float64 + wantPoints []float64 + wantRanks []int + }{ + { + // 修复前这里断言的是 [10 8 6 3 0 0 0]——整组被末位判据误伤,第 5、6 名白丢 2+1 分。 + name: "① 7 人三人并列第 5(占 5/6/7):不归零,按第 5 名给分", + values: []float64{1, 2, 3, 4, 5, 5, 5}, + wantPoints: []float64{10, 8, 6, 3, 2, 2, 2}, + wantRanks: []int{1, 2, 3, 4, 5, 5, 5}, + }, + { + name: "② 5 人独占第 5 名:这一档就是末位,归零", + values: []float64{1, 2, 3, 4, 5}, + wantPoints: []float64{10, 8, 6, 3, 0}, + wantRanks: []int{1, 2, 3, 4, 5}, + }, + { + name: "③ 6 人末位与第 5 名并列(占 5/6):起始名次 5 < 末位 6,不归零", + values: []float64{1, 2, 3, 4, 5, 5}, + wantPoints: []float64{10, 8, 6, 3, 2, 2}, + wantRanks: []int{1, 2, 3, 4, 5, 5}, + }, + { + name: "7 人两人并列第 6(占 6/7):不归零,按第 6 名给分", + values: []float64{1, 2, 3, 4, 5, 6, 6}, + wantPoints: []float64{10, 8, 6, 3, 2, 1, 1}, + wantRanks: []int{1, 2, 3, 4, 5, 6, 6}, + }, + { + name: "7 人独占第 7 名:真末位,归零", + values: []float64{1, 2, 3, 4, 5, 6, 7}, + wantPoints: []float64{10, 8, 6, 3, 2, 1, 0}, + wantRanks: []int{1, 2, 3, 4, 5, 6, 7}, + }, + { + name: "仅 1 人参赛:唯一参赛者就是末位,归零", + values: []float64{1}, + wantPoints: []float64{0}, + wantRanks: []int{1}, + }, + { + // 全场并列时起始名次是 1,没人在「末位那一档」上,所以都不归零。 + name: "7 人全场并列第 1:起始名次 1 ≠ 末位 7,不归零", + values: []float64{1, 1, 1, 1, 1, 1, 1}, + wantPoints: []float64{10, 10, 10, 10, 10, 10, 10}, + wantRanks: []int{1, 1, 1, 1, 1, 1, 1}, + }, + { + name: "2 人并列第 1(占 1/2):不归零,两人同分", + values: []float64{1, 1}, + wantPoints: []float64{10, 10}, + wantRanks: []int{1, 1}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + points, ranks := scoreValueChannel(t, profile, tc.values...) + assertPointsAndRanks(t, points, ranks, tc.wantPoints, tc.wantRanks) + }) + } +} + +// TestLastPlaceZeroTeamManualAndAggregateChannels 把同一条语义钉在另外两类名次来源上: +// 团体手工名次(篮球 / 足球等纯团体项目的主路径)与团体聚合通道共用 groupPoints, +// 末位判据必须同样只抹末位那一档。 +func TestLastPlaceZeroTeamManualAndAggregateChannels(t *testing.T) { + manual := teamTop6() + manual.LastPlaceZero = true + aggregate := teamTop6() + aggregate.LastPlaceZero = true + + t.Run("团体手工名次:7 队独占末位 → 第 7 名归零", func(t *testing.T) { + got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 6, 7}) + assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 1, 0}) + }) + + t.Run("团体手工名次:三人并列第 5(占 5/6/7)→ 不归零", func(t *testing.T) { + got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 5, 5}) + assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 2, 2}) + }) + + t.Run("团体手工名次:录入漏顺延 1,2,2,3,4,5,6 → 只抹被占用的末日档(第 6 名)", func(t *testing.T) { + // lastPlace 取「所有组占用名次的最大值」:录入员按最自然的顺延理解写 1,2,2,3,4,5,6 时, + // 最后一个队展示的是第 6 名,就是这份录入里的末位,仍应归零。 + got := scoreManualTeamChannel(t, manual, []int{1, 2, 2, 3, 4, 5, 6}) + assertFloatSlice(t, "各队积分", got, []float64{15, 10, 10, 8, 4, 2, 0}) + }) + + t.Run("团体手工名次:5 队独占第 5 名 → 归零", func(t *testing.T) { + got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5}) + assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 0}) + }) + + t.Run("团体聚合:6 队末位与第 5 名并列(占 5/6)→ 不归零", func(t *testing.T) { + got := scoreAggregateChannel(t, aggregate, AvgModeValid, 1, 2, 3, 4, 5, 5) + assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 2, 2}) + }) + + t.Run("团体聚合:5 队独占末位 → 归零", func(t *testing.T) { + got := scoreAggregateChannel(t, aggregate, AvgModeValid, 1, 2, 3, 4, 5) + assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 0}) + }) +} + +// TestDefaultConfigScoringUnchanged 是「默认配置逐字节不变」的回归: +// lastPlaceZero=false(现场多数小项在用)时修复只动了 `if profile.LastPlaceZero` 里面的判据, +// 这个分支根本不进,所以任何形态的结果都必须与修复前一致。这里把值直接写死。 +func TestDefaultConfigScoringUnchanged(t *testing.T) { + profile := individualTop6() + + cases := []struct { + name string + values []float64 + want []float64 + }{ + {"7 人三人并列第 5", []float64{1, 2, 3, 4, 5, 5, 5}, []float64{10, 8, 6, 3, 2, 2, 2}}, + {"6 人两人并列第 5", []float64{1, 2, 3, 4, 5, 5}, []float64{10, 8, 6, 3, 2, 2}}, + {"5 人独占第 5 名", []float64{1, 2, 3, 4, 5}, []float64{10, 8, 6, 3, 2}}, + {"7 人无并列", []float64{1, 2, 3, 4, 5, 6, 7}, []float64{10, 8, 6, 3, 2, 1, 0}}, + {"1 人参赛", []float64{1}, []float64{10}}, + {"2 人并列第 1", []float64{1, 1}, []float64{10, 10}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + points, _ := scoreValueChannel(t, profile, tc.values...) + assertFloatSlice(t, "积分", points, tc.want) + }) + } + + manual := teamTop6() + t.Run("团体手工名次", func(t *testing.T) { + got := scoreManualTeamChannel(t, manual, []int{1, 2, 3, 4, 5, 6, 7}) + assertFloatSlice(t, "各队积分", got, []float64{15, 10, 8, 4, 2, 1, 0}) + }) + + t.Run("团体聚合", func(t *testing.T) { + got := scoreAggregateChannel(t, manual, AvgModeValid, 1, 2, 3, 4, 5, 5) + assertFloatSlice(t, "各单位积分", got, []float64{15, 10, 8, 4, 2, 2}) + }) +} + +// defaultConfigGoldenSHA256 是 defaultConfigScoreDump 的签名,取自**修复前**的代码 +// (2026-09-13 任务 F 开工时记录)。覆盖:成绩通道 1..9 人 × 19 种成绩形态 × 三种并列口径、 +// 团体手工名次 14 种录入形态、团体聚合两种 avg_mode × 8 种成绩形态。 +// 这些形态在默认配置(lastPlaceZero=false)下一分都不该变。 +const defaultConfigGoldenSHA256 = "2c7425cb7371d584ca435403cea0ec9c03d069e6f08f6fdc21531845702b11f8" + +// TestDefaultConfigScoringDumpUnchanged 是本任务对「lastPlaceZero=false 逐字节一致」的全量兜底: +// 逐个形态的积分拼成一份签名与修复前对比。失败时会把完整签名打出来,方便定位是哪个形态变了。 +func TestDefaultConfigScoringDumpUnchanged(t *testing.T) { + dump := defaultConfigScoreDump(t) + sum := sha256.Sum256([]byte(dump)) + got := hex.EncodeToString(sum[:]) + if got != defaultConfigGoldenSHA256 { + t.Fatalf("lastPlaceZero=false 的计分结果与修复前不一致:\n签名 = %s\n期望 = %s\n\n%s", + got, defaultConfigGoldenSHA256, dump) + } +} + +// defaultConfigScoreDump 把默认配置下各形态的逐条积分拼成一份稳定文本。 +// 构造只依赖引擎输出,不依赖 map 遍历顺序,所以修复前后跑出来的文本可以直接比对。 +func defaultConfigScoreDump(t *testing.T) string { + t.Helper() + var lines []string + add := func(name string, points []float64) { + lines = append(lines, fmt.Sprintf("%s => %s", name, formatPoints(points))) + } + + valueShapes := [][]float64{ + {1}, {1, 2}, {1, 1}, {1, 2, 3}, {1, 1, 1}, {1, 2, 2}, + {1, 2, 3, 4}, {1, 2, 3, 5}, {1, 1, 2, 3}, {1, 1, 1, 1}, + {1, 2, 3, 4, 5}, {1, 2, 3, 4, 4}, {1, 2, 3, 4, 5, 5}, + {1, 1, 1, 1, 1, 1, 1}, {1, 2, 3, 4, 5, 5, 5}, {1, 2, 3, 4, 5, 6, 6}, + {1, 2, 3, 4, 5, 6, 7}, {1, 2, 3, 4, 5, 6, 7, 8}, {1, 2, 3, 4, 5, 6, 7, 8, 9}, + } + for _, tie := range []TieMode{TieSkip, TieAverage, TieHigher} { + for i, values := range valueShapes { + points, _ := scoreValueChannel(t, nineProfile(tie), values...) + add(fmt.Sprintf("result/%s/shape%02d/%s", tie, i, formatPoints(values)), points) + } + } + + manualShapes := [][]int{ + {1}, {1, 2}, {1, 1}, {1, 2, 3}, {1, 1, 1}, {1, 2, 2}, {1, 2, 4}, + {1, 2, 3, 4, 5}, {1, 2, 3, 4, 4}, {1, 2, 3, 4, 5, 5}, + {1, 2, 3, 4, 5, 6, 7}, {1, 2, 2, 3, 4, 5, 6}, {1, 2, 3, 4, 5, 5, 7}, + {1, 1, 1, 1, 1, 1, 1}, + } + for i, ranks := range manualShapes { + got := scoreManualTeamChannel(t, teamTop6(), ranks) + add(fmt.Sprintf("manual-team/%02d/%s", i, formatInts(ranks)), got) + } + + aggregateShapes := [][]float64{ + {1}, {1, 2}, {1, 1}, {1, 2, 3, 4, 5}, {1, 2, 3, 4, 5, 5}, + {1, 2, 3, 4, 5, 5, 5}, {1, 2, 3, 4, 5, 6, 7}, {1, 1, 1, 1, 1, 1, 1}, + } + for _, mode := range []AvgMode{AvgModeValid, AvgModeAll} { + for i, values := range aggregateShapes { + got := scoreAggregateChannel(t, teamTop6(), mode, values...) + add(fmt.Sprintf("aggregate/%s/%02d/%s", mode, i, formatPoints(values)), got) + } + } + + return strings.Join(lines, "\n") + "\n" +} + +func assertFloatSlice(t *testing.T, what string, got, want []float64) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s 条数 = %d,期望 %d(%v)", what, len(got), len(want), got) + } + for i := range want { + if !nearlyEqual(got[i], want[i]) { + t.Errorf("%s 第 %d 项 = %v,期望 %v(完整:%v)", what, i+1, got[i], want[i], got) + } + } +} + +func assertPointsAndRanks(t *testing.T, points []float64, ranks []int, wantPoints []float64, wantRanks []int) { + t.Helper() + assertFloatSlice(t, "积分", points, wantPoints) + if len(ranks) != len(wantRanks) { + t.Fatalf("名次条数 = %d,期望 %d(%v)", len(ranks), len(wantRanks), ranks) + } + for i := range wantRanks { + if ranks[i] != wantRanks[i] { + t.Errorf("第 %d 条名次 = %d,期望 %d(完整:%v)", i+1, ranks[i], wantRanks[i], ranks) + } + } +} + +func formatPoints(fs []float64) string { + parts := make([]string, len(fs)) + for i, f := range fs { + parts[i] = fmt.Sprintf("%v", f) + } + return "[" + strings.Join(parts, ", ") + "]" +} + +func formatInts(is []int) string { + parts := make([]string, len(is)) + for i, v := range is { + parts[i] = fmt.Sprintf("%d", v) + } + return "[" + strings.Join(parts, ", ") + "]" +} diff --git a/server/internal/domain/ledger.go b/server/internal/domain/ledger.go index 6b9472c..4bafa80 100644 --- a/server/internal/domain/ledger.go +++ b/server/internal/domain/ledger.go @@ -10,6 +10,18 @@ type TeamPoints struct { // SumByTeam 汇总各单位得分:总分 = 仅记单位的流水 + 系数 × 记到个人的流水。 // weight <= 0 时按 1 处理,避免零值把个人分抹掉。 // count_to_team = 0 的流水(如「个人+团体」项目的个人通道)只记在个人头上,不计入单位总分。 +// +// 刻意不按 EventID 过滤(也不该有人来「补」这条过滤): +// - 「event_id 为空」不等于「孤儿流水」。人工调整(source_type='adjustment')本来就允许不挂小项, +// 有 event_id 为 NULL 的合法行(写入口 repo_misc.go 的 createAdjustment,由迁移测试钉住), +// 一刀切过滤会把合法的调整分一起吞掉; +// - 就算再收窄到 source_type='result',过滤也只是「让分悄悄消失」:没有作废人、没有作废原因、 +// 没有审计行。积分变动必须可追溯,所以作废只能发生在写侧(如 deleteEvent 在同一事务里 +// 按 void_reason 作废),而不是读取时假装看不见; +// - 同一个「删掉的小项」在别处仍会被读到:单位积分明细(httpapi/repo_misc.go 的 teamBreakdown) +// 是另一条 SQL,过滤这里只会变成「榜上无分、明细有分」的新矛盾。 +// +// 因此本函数只认调用方给它的流水:要让它少算分,就在写侧把流水作废掉。 func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints { if weight <= 0 { weight = 1 @@ -33,6 +45,7 @@ func SumByTeam(entries []LedgerEntry, weight float64) map[int64]TeamPoints { // SumByAthlete 汇总每个运动员的个人总分(原始分,不折算系数)。 // 不计入单位总分的流水(count_to_team = 0)照样算在个人头上,个人榜不受影响。 +// 与 SumByTeam 同口径,同样不按 EventID 过滤(理由见 SumByTeam 的注释)。 func SumByAthlete(entries []LedgerEntry) map[int64]float64 { out := make(map[int64]float64) for _, e := range entries { diff --git a/server/internal/domain/scoring.go b/server/internal/domain/scoring.go index 93bfc83..5ec8dff 100644 --- a/server/internal/domain/scoring.go +++ b/server/internal/domain/scoring.go @@ -365,16 +365,18 @@ func groupByManualRank(cands []candidate) []tieGroup { // rankGroups 给每组分配积分并产出名次明细。 func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, groups []tieGroup) []RankedResult { - lastRank := 0 + // lastPlace 是本组别被占用的最大名次档,也就是「末位」那一档:skip 口径下各组的占位 + // 连续铺满 1..N,它正好等于实际可排名人数 N。末位归零只抹这一档,见 groupPoints。 + lastPlace := 0 for _, g := range groups { - if r := g.startRank + len(g.members) - 1; r > lastRank { - lastRank = r + if r := g.startRank + len(g.members) - 1; r > lastPlace { + lastPlace = r } } var out []RankedResult for _, g := range groups { - pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category) + pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category) for _, c := range g.members { out = append(out, rankedResult(in, ch, c, g.startRank, pts)) } @@ -383,14 +385,18 @@ func rankGroups(in EventScoreInput, ch ScoreChannel, profile ScoringProfile, gro } // groupPoints 计算一组并列者拿到的分。 -// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1。 -func groupPoints(profile ScoringProfile, startRank, size, lastRank int, ev Event, cat EventCategory) float64 { +// 三种口径都先确定这组「占用了哪些名次」:startRank .. startRank+size-1; +// lastPlace 是本组别被占用的最大名次档(末位那一档,skip 口径下等于实际可排名人数 N)。 +func groupPoints(profile ScoringProfile, startRank, size, lastPlace int, ev Event, cat EventCategory) float64 { if startRank <= 0 || size <= 0 { return 0 } - // lastPlaceZero 是对「最后一名」的覆盖;并列在末位的整组都按最后一名处理, - // 否则同组出现 0 与 1 两种分数,违背「并列同分」的语义。 - if profile.LastPlaceZero && startRank <= lastRank && lastRank <= startRank+size-1 { + // lastPlaceZero 只把「末位那一档」置零:判据是起始名次**正好落在这档**。 + // 这种组必然只有一个人(startRank == lastPlace 意味着 size == 1,因为 lastPlace 是所有组 + // 占位名次的最大值)。并列组若从更靠前的名次起步、区间跨过末位,组内多数人本可拿到录取线内的分, + // 整组归零会把他们一起抹掉——7 人里三人并列第 5(占 5/6/7)时,第 5 名的 2 分与第 6 名的 1 分 + // 都该照发,而他们并不是「末位那一档」。 + if profile.LastPlaceZero && startRank == lastPlace { return 0 } @@ -459,13 +465,21 @@ func rankedResult(in EventScoreInput, ch ScoreChannel, c candidate, rank int, po return rr } -// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩取平均, +// scoreByAggregate 实现 basis=aggregate:把同一主体(单位/运动员)在本组别的成绩汇成一个值, // 得到团体成绩后按项目的 compare 方向排名(并列沿用模板的 tieMode,默认顺延)。 -// 平均值分母由项目的 avg_mode 决定: +// +// 汇总口径(通道的 aggregate_mode,缺省 avg): +// - avg:团体成绩 = 成员成绩的平均值; +// - sum:团体成绩 = 成员成绩的总和。 +// +// avg 的平均值分母由项目的 avg_mode 决定: // - valid:分子 = 有效成绩之和,分母 = 有有效成绩的人数;一个人都没成绩就不参与排名、不得分; // - all:分子 = 有效成绩之和(没成绩的按 0),分母 = 该主体的报名人数。 // 没有报名项的主体不会成桶,自然不参与。 // +// sum 只累加有效成绩(没成绩的人按 0 计入和不计入是一样的和),所以 avg_mode 在这里只剩一个作用: +// 决定「一个有效成绩都没有的单位算不算 0 分参与排名」——valid 不参与、all 参与,与 avg 完全一致。 +// // 弃权 / 失格(status 非 registered)的报名项既不算参与也不算进分母,与「不占名次」的既有口径一致。 func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfile) ([]RankedResult, error) { if in.Event.Compare == CompareNone { @@ -510,18 +524,24 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil b.valid++ } } + sumMode := ch.aggregateMode() == AggregateSum + allMode := in.Event.avgMode() == AvgModeAll agg := make([]candidate, 0, len(order)) for _, id := range order { b := buckets[id] + if b.valid == 0 && !allMode { + // 一个人都没有效成绩的单位不参与团体排名,也不得分(两种汇总口径都一样)。 + continue + } var value float64 - if in.Event.avgMode() == AvgModeAll { + switch { + case sumMode: + // 求和口径:只累加有效成绩(没成绩按 0 计入与否不改变和)。 + value = b.sum + case allMode: // 分母 = 报名人数:没成绩的人按 0 计入,也就是只累加有效成绩之和。 value = b.sum / float64(b.roster) - } else { - if b.valid == 0 { - // 一个人都没有效成绩的单位不参与团体排名,也不得分。 - continue - } + default: value = b.sum / float64(b.valid) } c := candidate{entry: b.entry, subject: b.subject, value: value} @@ -531,16 +551,17 @@ func scoreByAggregate(in EventScoreInput, ch ScoreChannel, profile ScoringProfil sortCandidatesByValue(agg, in.Event.Compare) groups := groupByValue(agg) - lastRank := 0 + // 与 rankGroups 同一个口径:lastPlace = 被占用的最大名次档(聚合结果同样是 skip 占位)。 + lastPlace := 0 for _, g := range groups { - if r := g.startRank + len(g.members) - 1; r > lastRank { - lastRank = r + if r := g.startRank + len(g.members) - 1; r > lastPlace { + lastPlace = r } } var out []RankedResult for _, g := range groups { - pts := groupPoints(profile, g.startRank, len(g.members), lastRank, in.Event, in.Category) + pts := groupPoints(profile, g.startRank, len(g.members), lastPlace, in.Event, in.Category) for _, c := range g.members { rr := RankedResult{ EventID: in.Event.ID, diff --git a/server/internal/domain/scoring_test.go b/server/internal/domain/scoring_test.go index ab6b4ee..ae23d4c 100644 --- a/server/internal/domain/scoring_test.go +++ b/server/internal/domain/scoring_test.go @@ -31,7 +31,8 @@ func TestGroupPointsTieModes(t *testing.T) { {"skip 超出录取名次", nineProfile(TieSkip), 9, 1, 9, 0}, {"空模板口径按 skip", ScoringProfile{AwardRanks: 8, Rules: nineProfile(TieSkip).Rules}, 2, 1, 8, 7}, {"lastPlaceZero 抹掉最后一名", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 1, 1, 1, 0}, - {"lastPlaceZero 抹掉并列末位整组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 0}, + // 末位档是 3(三人参赛),这组从第 2 名起步、区间跨过末位 → 只吞末位那一档,不整组归零。 + {"lastPlaceZero 不牵连跨末位的并列组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 7}, {"权重相乘", nineProfile(TieSkip), 1, 1, 8, 9}, } for _, tc := range cases { diff --git a/server/internal/domain/standings.go b/server/internal/domain/standings.go index f3c3035..37b8e6a 100644 --- a/server/internal/domain/standings.go +++ b/server/internal/domain/standings.go @@ -19,6 +19,13 @@ var ( defaultAthleteTieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakName} ) +// 单位奖牌榜的排序口径(主人 2026-09-13 拍板):金牌 → 银牌 → 铜牌 → 单位编码。 +// +// 它**故意不读** StandingsInput.TieBreak:那套键是给总积分榜与个人榜的, +// 规则包里写的 `points,gold,...` 一进来,奖牌榜就又变回"总分优先", +// 会出现"金牌 6 枚的单位排在金牌 2 枚的后面"(06 报告 P1④)。 +var defaultMedalTieBreak = []string{TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode} + // StandingsInput 是排名聚合的输入:流水 + 名次明细 + 单位/运动员基础信息。 type StandingsInput struct { Teams []Team @@ -27,6 +34,7 @@ type StandingsInput struct { Ranked []RankedResult // TieBreak 是排序键顺序(如 points/gold/silver/bronze/teamCode/name); // 空表示用默认口径。配置里的键优先,其余按默认顺序补齐,保证结果全序。 + // **只作用于总积分榜与个人榜**:单位奖牌榜固定按金→银→铜→单位编码排(defaultMedalTieBreak)。 TieBreak []string // IndividualToTeamWeight 个人积分折算进单位总分的系数;<=0 时按 1 处理。 IndividualToTeamWeight float64 @@ -61,6 +69,19 @@ func medalEligible(rr RankedResult) bool { // BuildTeamStandings 生成总积分榜。 // 总分 = Σ(仅记单位的流水) + 系数 × Σ(记到个人的流水);排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 单位编码。 func BuildTeamStandings(in StandingsInput) []StandingsRow { + rows := buildTeamRows(in) + sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) }) + for i := range rows { + rows[i].Rank = i + 1 + } + return rows +} + +// buildTeamRows 汇总出「每个单位一行」的原始数据(含兜底补行),不排序、不填名次。 +// +// 单位总分榜与单位奖牌榜的行集合必须完全一致(同一批单位、同样兜底), +// 差别只在排序与名次;两条路都从这里出行,避免哪天只给一个榜补了兜底行。 +func buildTeamRows(in StandingsInput) []StandingsRow { weight := effectiveWeight(in.IndividualToTeamWeight) sums := SumByTeam(in.Ledger, weight) medals := teamMedals(in) @@ -87,22 +108,19 @@ func BuildTeamStandings(in StandingsInput) []StandingsRow { rows = append(rows, buildTeamRow(Team{ID: id}, sums[id], m)) } } - - sort.SliceStable(rows, func(i, j int) bool { return lessTeamRow(rows[i], rows[j], in.TieBreak) }) - for i := range rows { - rows[i].Rank = i + 1 - } return rows } // BuildMedalStandings 生成单位奖牌榜。 -// 排序口径与总积分榜一致(总分优先),只是列不同,保证两块榜的单位顺序不会打架。 +// +// 行集合与总积分榜相同(同一份输入、同一套兜底),但**名次与顺序自成一套**: +// 金 → 银 → 铜 → 单位编码,与总分无关(主人 2026-09-13 拍板)。 +// 因此绝不能复用总积分榜的 Rank——那正是「金牌多的排在金牌少的后面」的来源。 func BuildMedalStandings(in StandingsInput) []MedalRow { - teamRows := BuildTeamStandings(in) - out := make([]MedalRow, len(teamRows)) - for i, r := range teamRows { - out[i] = MedalRow{ - Rank: r.Rank, + teamRows := buildTeamRows(in) + out := make([]MedalRow, 0, len(teamRows)) + for _, r := range teamRows { + out = append(out, MedalRow{ TeamID: r.TeamID, Code: r.Code, Name: r.Name, @@ -112,11 +130,42 @@ func BuildMedalStandings(in StandingsInput) []MedalRow { Silver: r.Silver, Bronze: r.Bronze, Total: r.Gold + r.Silver + r.Bronze, - } + }) + } + sort.SliceStable(out, func(i, j int) bool { return lessMedalRow(out[i], out[j]) }) + for i := range out { + out[i].Rank = i + 1 } return out } +// lessMedalRow 是单位奖牌榜的排序:金 DESC → 银 DESC → 铜 DESC → 单位编码 ASC。 +// 只用 defaultMedalTieBreak 这几个键(不掺 StandingsInput.TieBreak),最后用单位 ID 兜底, +// 保证任意两行都有确定先后(结果全序)。 +func lessMedalRow(a, b MedalRow) bool { + for _, key := range defaultMedalTieBreak { + switch key { + case TieBreakGold: + if a.Gold != b.Gold { + return a.Gold > b.Gold + } + case TieBreakSilver: + if a.Silver != b.Silver { + return a.Silver > b.Silver + } + case TieBreakBronze: + if a.Bronze != b.Bronze { + return a.Bronze > b.Bronze + } + case TieBreakTeamCode: + if a.Code != b.Code { + return a.Code < b.Code + } + } + } + return a.TeamID < b.TeamID +} + // BuildAthleteStandings 生成个人积分奖牌榜。 // 排序按 in.TieBreak,缺省为总分 → 金 → 银 → 铜 → 姓名;个人榜的奖牌只统计个人项目(团体项目奖牌归团体榜)。 func BuildAthleteStandings(in StandingsInput) []AthleteStandingsRow { diff --git a/server/internal/domain/standings_test.go b/server/internal/domain/standings_test.go index b18c16e..8bd50d3 100644 --- a/server/internal/domain/standings_test.go +++ b/server/internal/domain/standings_test.go @@ -147,6 +147,94 @@ func TestMedalStandings(t *testing.T) { } } +// medalRanked 造一条能拿奖牌的名次明细:一个小项一条,避免被「同一主体只记一枚」去重掉。 +func medalRanked(teamID, eventID int64, rank int) RankedResult { + rr := mkRanked(teamID, ScopeTeam, rank) + rr.EventID = eventID + rr.CategoryID = 1 + return rr +} + +// medalPodium 给一个单位凑出金/银/铜若干枚(各占一个小项),firstEventID 用来错开小项。 +func medalPodium(teamID, firstEventID int64, gold, silver, bronze int) []RankedResult { + out := make([]RankedResult, 0, gold+silver+bronze) + next := firstEventID + for i := 0; i < gold; i++ { + out = append(out, medalRanked(teamID, next, 1)) + next++ + } + for i := 0; i < silver; i++ { + out = append(out, medalRanked(teamID, next, 2)) + next++ + } + for i := 0; i < bronze; i++ { + out = append(out, medalRanked(teamID, next, 3)) + next++ + } + return out +} + +// TestMedalStandingsIndependentOfPointsBoard 是 06 报告 P1④ 的回归用例(主人 2026-09-13 拍板): +// 单位1 金牌 6 枚但只有 10 分,单位2 金牌 2 枚却拿了 30 分。 +// 修前:单位奖牌榜直接沿用总积分榜的顺序与名次,单位1 排在单位2 后面(观众看到"金牌多的排后面")。 +// 修后:奖牌榜按金→银→铜排,单位1 榜首;总积分榜仍按总分排,单位2 榜首——两个榜解耦。 +func TestMedalStandingsIndependentOfPointsBoard(t *testing.T) { + ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 30)} + ranked := append(medalPodium(1, 1, 6, 0, 0), medalPodium(2, 101, 2, 0, 0)...) + in := StandingsInput{Teams: testTeams(1, 2), Ledger: ledger, Ranked: ranked} + + points := BuildTeamStandings(in) + if len(points) != 2 || points[0].TeamID != 2 || points[0].Rank != 1 || points[1].TeamID != 1 || points[1].Rank != 2 { + t.Fatalf("总积分榜必须仍按总分排(单位2 30 分在前,名次 1):%+v", points) + } + + medal := BuildMedalStandings(in) + if len(medal) != 2 { + t.Fatalf("奖牌榜行数 = %d,期望 2", len(medal)) + } + if medal[0].TeamID != 1 || medal[0].Rank != 1 || medal[0].Gold != 6 { + t.Fatalf("奖牌榜榜首应为金牌 6 枚的单位1 且名次 1:%+v", medal) + } + if medal[1].TeamID != 2 || medal[1].Rank != 2 || medal[1].Gold != 2 { + t.Fatalf("奖牌榜次席应为金牌 2 枚的单位2 且名次 2:%+v", medal) + } + // 名次重排了,但总分列仍照实带出:单位2 在奖牌榜上依然是 30 分。 + if !nearlyEqual(medal[1].Points, 30) { + t.Fatalf("奖牌榜的总分列 = %v,期望 30", medal[1].Points) + } + + // 规则包的 tie_break 以 points 打头,也不能把奖牌榜拉回总分口径。 + configured := in + configured.TieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode} + if got := BuildMedalStandings(configured); got[0].TeamID != 1 || got[0].Rank != 1 { + t.Fatalf("tie_break=points,… 时奖牌榜榜首 = 单位 %d,期望仍为金牌多的单位1:%+v", got[0].TeamID, got) + } +} + +// 奖牌相同时才比下一级:银 → 铜 → 单位编码,名次始终是 1..n 的全序。 +func TestMedalStandingsSortChain(t *testing.T) { + cases := []struct { + name string + ranked []RankedResult + want int64 + }{ + {"金相同比银", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 1, 0)...), 2}, + {"金、银相同比铜", append(medalPodium(1, 1, 1, 1, 0), medalPodium(2, 101, 1, 1, 1)...), 2}, + {"奖牌全同按单位编码 A < B", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 0, 0)...), 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rows := BuildMedalStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: tc.ranked}) + if rows[0].TeamID != tc.want { + t.Fatalf("榜首 = 单位 %d,期望 %d:%+v", rows[0].TeamID, tc.want, rows) + } + if rows[0].Rank != 1 || rows[1].Rank != 2 { + t.Fatalf("名次 = %d/%d,期望 1/2", rows[0].Rank, rows[1].Rank) + } + }) + } +} + func TestAthleteStandings(t *testing.T) { ledger := []LedgerEntry{ ledgerForAthlete(1001, 1, 10), diff --git a/server/internal/domain/types.go b/server/internal/domain/types.go index eed0265..61a77d5 100644 --- a/server/internal/domain/types.go +++ b/server/internal/domain/types.go @@ -35,6 +35,17 @@ const ( AvgModeAll AvgMode = "all" ) +// AggregateMode 聚合通道(basis=aggregate)的汇总口径:把同一主体的成绩汇成一个值的方式。 +// 与 AvgMode(平均值内部的分母口径)是两个维度,可以组合,见 scoreByAggregate。 +type AggregateMode string + +const ( + // AggregateAvg 按主体成员成绩的平均值排名(缺省口径,与升级前行为一致)。 + AggregateAvg AggregateMode = "avg" + // AggregateSum 按主体成员成绩的总和排名。 + AggregateSum AggregateMode = "sum" +) + // ResultType 成绩的物理量类型,决定比较与展示方式。 type ResultType string @@ -229,7 +240,8 @@ type EventCategory struct { // ScoreChannel 计分通道 = 「排谁的名次」+「名次从哪来」+「用哪张积分表」。 // 一个项目可配 0..N 个通道;0 个表示只排名不计分。 -// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。 +// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由本通道的 aggregate_mode 决定 +// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。 type ScoreChannel struct { ID int64 `json:"id"` EventID int64 `json:"eventId"` @@ -237,6 +249,9 @@ type ScoreChannel struct { Basis Basis `json:"basis"` ProfileID int64 `json:"profileId"` SortOrder int `json:"sortOrder,omitempty"` + // AggregateMode 是 basis=aggregate 通道的汇总口径;空值按缺省(avg)处理, + // 只对 aggregate 通道有意义,其它通道即使带着该字段也不参与计算。 + AggregateMode AggregateMode `json:"aggregateMode,omitempty"` // CountToTeam 决定该通道产出的流水是否计入单位总分;nil 表示按缺省(计入)处理。 // 「个人+团体」项目的个人通道置 false:分只记在个人头上,不进单位总分。 CountToTeam *bool `json:"countToTeam,omitempty"` @@ -462,6 +477,15 @@ func (c ScoreChannel) countToTeam() bool { return c.CountToTeam == nil || *c.CountToTeam } +// aggregateMode 未配置或配错时按 avg 处理(与库里的默认值一致): +// 缺字段的老数据、老客户端保存的通道都维持「按成员平均」的既有口径。 +func (c ScoreChannel) aggregateMode() AggregateMode { + if c.AggregateMode == AggregateSum { + return AggregateSum + } + return AggregateAvg +} + // CountsToMedals 未配置时按 true 处理:只有显式关闭的通道才不发奖牌。 func (c ScoreChannel) CountsToMedals() bool { return c.CountToMedals == nil || *c.CountToMedals diff --git a/server/internal/dto/standings.go b/server/internal/dto/standings.go index 8d41c72..85626e0 100644 --- a/server/internal/dto/standings.go +++ b/server/internal/dto/standings.go @@ -6,6 +6,9 @@ import "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" // // 字段名与 web/src/shared/types.ts 的 StandingRow 对齐:大屏与管理端共用一份解析逻辑, // 不能再把 domain.StandingsRow 的 points/code/medals 直接下发。 +// +// 两个榜共用这一行:rank 是总积分榜名次(总分优先),medalRank 是单位奖牌榜名次 +// (金 → 银 → 铜 → 单位编码,与总分无关)。金牌多但总分低的单位,medalRank 会比 rank 靠前。 type StandingRow struct { TeamID int64 `json:"teamId"` TeamCode string `json:"teamCode"` @@ -14,6 +17,7 @@ type StandingRow struct { Color *string `json:"color"` LogoPath *string `json:"logoPath"` Rank int `json:"rank"` + MedalRank int `json:"medalRank"` Gold int `json:"gold"` Silver int `json:"silver"` Bronze int `json:"bronze"` @@ -23,12 +27,21 @@ type StandingRow struct { } // StandingRows 把 domain 榜单转换成线上结构。 +// +// rows 是总积分榜(顺序与 Rank 都按总分),medal 是同一份输入算出的单位奖牌榜 +// (顺序与名次按金→银→铜)。两个榜的行集合相同,这里用团队 id 把奖牌榜名次并进每一行, +// 于是 REST / WS 快照 / xlsx 导出三处拿到的奖牌榜口径完全一致(同一个 domain.BuildMedalStandings)。 +// // teams 用于补齐颜色/logo 等 domain 榜单没带的展示字段;rankChange 快照没有对比基准,与 REST 一致下发 null。 -func StandingRows(rows []domain.StandingsRow, teams []domain.Team) []StandingRow { +func StandingRows(rows []domain.StandingsRow, medal []domain.MedalRow, teams []domain.Team) []StandingRow { byID := make(map[int64]domain.Team, len(teams)) for _, t := range teams { byID[t.ID] = t } + medalRanks := make(map[int64]int, len(medal)) + for _, m := range medal { + medalRanks[m.TeamID] = m.Rank + } out := make([]StandingRow, 0, len(rows)) for _, r := range rows { @@ -39,6 +52,7 @@ func StandingRows(rows []domain.StandingsRow, teams []domain.Team) []StandingRow TeamName: r.Name, ShortName: nilIfEmpty(r.ShortName), Rank: r.Rank, + MedalRank: medalRanks[r.TeamID], Gold: r.Gold, Silver: r.Silver, Bronze: r.Bronze, diff --git a/server/internal/export/load.go b/server/internal/export/load.go index fdc0ee3..c53aacf 100644 --- a/server/internal/export/load.go +++ b/server/internal/export/load.go @@ -1,6 +1,7 @@ package export import ( + "context" "database/sql" "encoding/json" "fmt" @@ -9,6 +10,7 @@ import ( "time" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" + "git.yeij.top/AskaEth/Scoreboard/server/internal/revisions" ) // Meta 是导出件抬头里的赛事信息;留空的字段自动回落到赛事库的 settings。 @@ -646,7 +648,8 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { SELECT COALESCE(s.name, ''), COALESCE(e.name, ''), COALESCE(c.name, ca.name, ''), COALESCE(a.name, ''), `+teamDetailRankExpr+`, COALESCE(r.result_text, ''), COALESCE(r.unit, ''), - l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0) + l.source_type, l.points, COALESCE(l.void_reason, ''), COALESCE(l.athlete_id, 0), + COALESCE(l.event_id, 0), COALESCE(l.entry_id, 0) FROM score_ledger l LEFT JOIN events e ON e.id = l.event_id LEFT JOIN sports s ON s.id = e.sport_id @@ -673,13 +676,15 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { defer func() { _ = rows.Close() }() var out []TeamDetailRow + // 每条流水背后的成绩身份((小项, 报名项),口径见 internal/revisions),用来补「备注」列的修订痕迹。 + keys := make([]revisions.Key, 0, len(out)) for rows.Next() { var r TeamDetailRow var source string - var athleteID int64 + var athleteID, eventID, entryID int64 var resultText, unit string if err := rows.Scan(&r.SportName, &r.EventName, &r.Category, &r.AthleteName, &r.Rank, - &resultText, &unit, &source, &r.Points, &r.Remark, &athleteID); err != nil { + &resultText, &unit, &source, &r.Points, &r.Remark, &athleteID, &eventID, &entryID); err != nil { return nil, fmt.Errorf("export: 扫描积分明细: %w", err) } // 成绩与分项成绩表 / 个人明细同口径(走 displayResult),否则同一场比赛的同一个成绩 @@ -687,14 +692,70 @@ func loadTeamDetailRows(db *sql.DB, teamID int64) ([]TeamDetailRow, error) { r.ResultText = displayResult(resultText, unit) r.Source = sourceLabel(source) r.individual = athleteID != 0 + keys = append(keys, revisions.KeyFor(source, eventID, entryID)) out = append(out, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("export: 遍历积分明细: %w", err) } + attachRevisionRemarks(out, keys, loadRevisionTrails(db, keys)) return out, nil } +// loadRevisionTrails 取这些成绩的修订痕迹;查不出来只留空,导出照旧出。 +// +// 导出件与屏幕上写的必须是同一句话(口径见 internal/revisions):明细在屏幕上挂着 +// 「已修订 ×2」,xlsx 里一个字没有,事后拿表格对账的人就不知道该信哪份。 +func loadRevisionTrails(db *sql.DB, keys []revisions.Key) map[revisions.Key]revisions.Trail { + trails, err := revisions.Load(context.Background(), db, keys) + if err != nil { + return nil + } + return trails +} + +// attachRevisionRemarks 把修订痕迹写进「备注」列:`已修订 ×2;最后一次 10:42 成绩录入笔误`。 +// +// 没改过(或追不出来)的行备注留空 —— 这个列本来只写「作废理由」,而作废的流水不会出现在这里, +// 所以正常行本来就是空白,只有真被改过的成绩才会多出一句话。 +func attachRevisionRemarks(rows []TeamDetailRow, keys []revisions.Key, trails map[revisions.Key]revisions.Trail) { + for i, key := range keys { + if i >= len(rows) { + return + } + trail, ok := trails[key] + if !ok || trail.Count == 0 { + continue + } + text := fmt.Sprintf("已修订 ×%d", trail.Count) + if clock := revisionClock(trail.At); clock != "" { + text += ";最后一次 " + clock + if trail.Reason != "" { + text += " " + trail.Reason + } + } else if trail.Reason != "" { + text += ";" + trail.Reason + } + if rows[i].Remark == "" { + rows[i].Remark = text + } else { + rows[i].Remark += ";" + text + } + } +} + +// revisionClock 把 result_revisions.created_at 转成 `HH:mm`(本地时区)。 +// +// 库里存的是 datetime('now') 的 UTC 但不带时区,按 UTC 解析再转本地 —— 与前端 +// shared/format.ts 的 toDate 同一口径,否则导出件上的 10:42 会比大屏早 8 小时。 +func revisionClock(at string) string { + parsed, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(at), time.UTC) + if err != nil { + return "" + } + return parsed.Local().Format("15:04") +} + func rankLabel(rank int, isTie bool) string { if rank <= 0 { return "" diff --git a/server/internal/export/load_test.go b/server/internal/export/load_test.go index 1b22ae1..26b3779 100644 --- a/server/internal/export/load_test.go +++ b/server/internal/export/load_test.go @@ -3,8 +3,15 @@ package export import ( "context" "database/sql" + "encoding/json" + "path/filepath" + "regexp" + "strings" "testing" + "github.com/xuri/excelize/v2" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" ) @@ -27,6 +34,10 @@ func TestExportStandingsMatchesSnapshot(t *testing.T) { if len(wb.Team) != len(snap.Standings) { t.Fatalf("单位数不一致:导出 %d,快照 %d", len(wb.Team), len(snap.Standings)) } + medalRank := make(map[int64]int, len(wb.Medal)) + for _, m := range wb.Medal { + medalRank[m.TeamID] = m.Rank + } for _, s := range snap.Standings { idx := -1 for i := range wb.Team { @@ -43,6 +54,10 @@ func TestExportStandingsMatchesSnapshot(t *testing.T) { row.Bronze != s.Bronze || row.Medals != s.MedalTotal || row.Rank != s.Rank { t.Errorf("单位 %d 口径不一致:导出 %+v,快照 %+v", s.TeamID, row, s) } + // 单位奖牌榜的名次也必须同源:xlsx 的奖牌榜名次 = 快照下发的 medalRank。 + if got := medalRank[s.TeamID]; got != s.MedalRank { + t.Errorf("单位 %d 的奖牌榜名次不一致:导出 %d,快照 %d", s.TeamID, got, s.MedalRank) + } } } @@ -138,6 +153,107 @@ func TestLoadStandingsHonoursTieBreak(t *testing.T) { } } +// 单位奖牌榜独立于积分榜(主人 2026-09-13 拍板,06 报告 P1④): +// T1 有 1 金但只 5 分、T2 无牌却 10 分——总积分榜 T2 在前,奖牌榜必须 T1 在前。 +// 三处同源:xlsx 的「单位奖牌榜」sheet 与 WS 快照下发的 medalRank 都出自 domain.BuildMedalStandings。 +func TestLoadStandingsMedalBoardIndependentOfPoints(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + // 与 TestLoadStandingsHonoursTieBreak 同一份造数:T1 金牌 1 / 5 分,T2 无牌 / 10 分。 + for _, s := range []string{ + `UPDATE score_ledger SET points = 5 WHERE team_id = 1`, + `UPDATE score_ledger SET points = 10 WHERE team_id = 2`, + } { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造数据 %q: %v", s, err) + } + } + seedChannelRanks(t, db) + + wb, err := LoadStandingsWorkbook(db, Options{}) + if err != nil { + t.Fatalf("导出总表: %v", err) + } + if wb.Team[0].Code != "T2" { + t.Fatalf("总积分榜仍按总分排,榜首 = %q,期望 T2", wb.Team[0].Code) + } + if len(wb.Medal) != 2 || wb.Medal[0].Code != "T1" || wb.Medal[0].Rank != 1 || wb.Medal[0].Gold != 1 { + t.Fatalf("奖牌榜应按金牌排,榜首 = %+v,期望 T1(1 金、名次 1)", wb.Medal) + } + if wb.Medal[1].Code != "T2" || wb.Medal[1].Rank != 2 { + t.Fatalf("奖牌榜次席 = %+v,期望 T2(名次 2)", wb.Medal[1]) + } + + // WS 快照(大屏推送)必须给出同一套奖牌榜名次。 + hub := realtime.NewHub(db, testLogger()) + defer func() { _ = hub.Close() }() + snap, err := hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("读取 WS 快照: %v", err) + } + rows := make(map[string]dto.StandingRow, len(snap.Standings)) + for _, r := range snap.Standings { + rows[r.TeamCode] = r + } + if rows["T1"].MedalRank != 1 || rows["T2"].MedalRank != 2 { + t.Fatalf("快照的奖牌榜名次 = T1:%d T2:%d,期望 1 / 2", rows["T1"].MedalRank, rows["T2"].MedalRank) + } + // 两套名次确实解耦:T1 总分低,总积分榜名次仍是 2。 + if rows["T1"].Rank != 2 || rows["T2"].Rank != 1 { + t.Fatalf("快照的总积分榜名次 = T1:%d T2:%d,期望 2 / 1", rows["T1"].Rank, rows["T2"].Rank) + } + + // xlsx 的「单位奖牌榜」sheet 必须照同一个顺序落表(表头下一行就是第一名)。 + path := filepath.Join(t.TempDir(), "standings.xlsx") + if err := ExportStandingsXLSX(db, path, Options{}); err != nil { + t.Fatalf("导出总表 xlsx: %v", err) + } + f, err := excelize.OpenFile(path) + if err != nil { + t.Fatalf("打开导出件: %v", err) + } + defer func() { _ = f.Close() }() + sheet := sheetNamed(t, f, "单位奖牌榜") + sheetRows, err := f.GetRows(sheet) + if err != nil { + t.Fatalf("读取 %s: %v", sheet, err) + } + // 表头之前还有大标题 / 副标题 / 空行,按表头文字定位,别写死行号。 + head := -1 + for i, row := range sheetRows { + if len(row) > 0 && row[0] == "名次" { + head = i + break + } + } + if head < 0 { + t.Fatalf("%s 里找不到表头行:%v", sheet, sheetRows) + } + if len(sheetRows) < head+3 { + t.Fatalf("%s 数据行数 = %d,期望表头 + 2 行", sheet, len(sheetRows)-head-1) + } + // 列序:名次 / 单位代码 / 单位名称 / 金牌 / 银牌 / 铜牌 / 奖牌合计 / 总分。 + first, second := sheetRows[head+1], sheetRows[head+2] + if first[0] != "1" || first[1] != "T1" || first[3] != "1" { + t.Fatalf("奖牌榜首行 = %v,期望 1 / T1 / 1 金", first) + } + if second[0] != "2" || second[1] != "T2" { + t.Fatalf("奖牌榜次行 = %v,期望 2 / T2", second) + } +} + +// sheetNamed 按后缀取工作表名:导出件的工作表名带赛事名前缀(如「测试运动会 · 单位奖牌榜」)。 +func sheetNamed(t *testing.T, f *excelize.File, suffix string) string { + t.Helper() + for _, name := range f.GetSheetList() { + if strings.HasSuffix(name, suffix) { + return name + } + } + t.Fatalf("导出件里没有以 %q 结尾的工作表:%v", suffix, f.GetSheetList()) + return "" +} + // 停用但仍有积分的单位要显示编码/名称,不能是空行。 func TestLoadStandingsShowsInactiveTeam(t *testing.T) { db := openEventDB(t) @@ -605,6 +721,96 @@ func TestEventRowsIncludeDraftsKeepsDraftValues(t *testing.T) { } } +// 「备注」列要带上修订痕迹:屏幕上挂着「已修订 ×2」而导出件一个字没有, +// 事后拿表格对账的人就不知道该信哪份。判定口径见 internal/revisions。 +func TestTeamDetailRemarkCarriesRevisionTrail(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + seedDetailRevisions(t, db) + + wb, err := LoadTeamDetailWorkbook(db, 1, Options{}) + if err != nil { + t.Fatalf("读取积分明细: %v", err) + } + rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight) + if len(rows) < 2 { + t.Fatalf("明细行数 = %d,期望含合计行", len(rows)) + } + // 备注是第 10 列(下标 9)。按「来源」找行,不按下标 —— 行序由名次决定,别把断言写死在行号上。 + for _, row := range rows { + switch row[7] { + case "成绩": + if got, ok := row[9].(string); !ok || + !regexp.MustCompile(`^已修订 ×1;最后一次 \d{2}:\d{2} 成绩录入笔误$`).MatchString(got) { + t.Errorf("成绩行备注 = %v,期望 `已修订 ×1;最后一次 HH:MM 成绩录入笔误`", row[9]) + } + case "破纪录加分": + // 破纪录加分是站在成绩旁边的另一笔分,不继承成绩的修订痕迹(见 revisions.KeyFor)。 + if got, _ := row[9].(string); got != "" { + t.Errorf("破纪录加分行备注 = %q,期望留空(改的是成绩,不是这笔加分)", got) + } + } + } + // 合计行的备注留空,不被带上。 + if got, _ := rows[len(rows)-1][9].(string); got != "" { + t.Errorf("合计行备注 = %q,期望留空", got) + } +} + +// 没改过的成绩备注必须留空 —— 导出侧同样不许出现「其实没改过却写了已修订」。 +func TestTeamDetailRemarkEmptyWhenNeverRevised(t *testing.T) { + db := openEventDB(t) + seedEvent(t, db) + // 只发布过一版(有基线、没改动):能追溯,但一个字都不该写。 + writeRevision(t, db, "2026-09-13 02:40:00", "", []map[string]any{ + {"entryId": 1, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "status": "ok"}, + }) + + wb, err := LoadTeamDetailWorkbook(db, 1, Options{}) + if err != nil { + t.Fatalf("读取积分明细: %v", err) + } + rows := teamDetailRows(wb.Rows, wb.IndividualToTeamWeight) + if got, _ := rows[0][9].(string); got != "" { + t.Errorf("没改过的成绩备注 = %q,期望留空", got) + } +} + +// seedDetailRevisions 造一条「发布 → 改成绩 → 重新发布」的历史:张三那条 11.58 被改成 11.42。 +// 两条修订都带上同小项另一条成绩,证明「别人改了」不会把修订痕迹算到自己头上。 +func seedDetailRevisions(t *testing.T, db *sql.DB) { + t.Helper() + writeRevision(t, db, "2026-09-13 02:40:00", "", []map[string]any{ + {"entryId": 1, "rank": 1, "resultText": "11.58", "resultValue": 11.58, "unit": "s", "state": "published"}, + {"entryId": 2, "rank": 2, "resultText": "11.90", "resultValue": 11.90, "unit": "s", "state": "published"}, + }) + writeRevision(t, db, "2026-09-13 02:58:00", "", []map[string]any{ + {"entryId": 1, "rank": 1, "resultText": "11.42", "resultValue": 11.42, "unit": "s", "state": "published", + "note": "成绩录入笔误"}, + // 名次 2 → 1 是改张三的成绩后引擎重算的,李四这条自己没动过。 + {"entryId": 2, "rank": 1, "resultText": "11.90", "resultValue": 11.90, "unit": "s", "state": "published"}, + }) + // 同一报名项上再挂一条破纪录加分流水:它不该继承成绩的修订痕迹。 + if _, err := db.Exec(`INSERT INTO score_ledger(team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points) + VALUES (1, 1, 1, 1, 1, 'record_bonus', 1, 3)`); err != nil { + t.Fatalf("写入破纪录加分流水: %v", err) + } +} + +// writeRevision 往 result_revisions 里插一版快照(字段名与 httpapi.resultDTO 对齐)。 +func writeRevision(t *testing.T, db *sql.DB, createdAt, message string, results []map[string]any) { + t.Helper() + raw, err := json.Marshal(results) + if err != nil { + t.Fatalf("序列化快照: %v", err) + } + if _, err := db.Exec(`INSERT INTO result_revisions (event_id, action, snapshot_json, message, created_at) + VALUES (1, 'publish', ?, ?, ?)`, string(raw), message, createdAt); err != nil { + t.Fatalf("插入修订: %v", err) + } +} + // equalCells 逐格比较两行;float64 与 string 都按值比。 func equalCells(got, want []any) bool { if len(got) != len(want) { diff --git a/server/internal/export/medals.go b/server/internal/export/medals.go index a34a2d6..508ca57 100644 --- a/server/internal/export/medals.go +++ b/server/internal/export/medals.go @@ -157,7 +157,9 @@ func medalRow(r MedalDetailRow) []any { } // LoadMedalDetailWorkbook 读取全部奖牌来源。 -// 单位顺序沿用「单位奖牌榜」(与单位总分榜同序),没有奖牌的单位不出分表。 +// 单位顺序按「单位总分榜」(teamStandingsOrder,得分高的在前),**不跟单位奖牌榜**: +// 奖牌榜已独立按金→银→铜排(domain.BuildMedalStandings),两份顺序本来就不一样。 +// 没有奖牌的单位不出分表。 func LoadMedalDetailWorkbook(db *sql.DB, opts Options) (MedalDetailWorkbook, error) { if db == nil { return MedalDetailWorkbook{}, ErrNoDatabase diff --git a/server/internal/httpapi/breakdown_revisions_test.go b/server/internal/httpapi/breakdown_revisions_test.go new file mode 100644 index 0000000..19d9445 --- /dev/null +++ b/server/internal/httpapi/breakdown_revisions_test.go @@ -0,0 +1,233 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "testing" +) + +// breakdownItems 读某单位的积分明细行。 +func breakdownItems(t *testing.T, env *testEnv, client *http.Client, teamID int64) []map[string]any { + t.Helper() + status, env2 := env.do(client, http.MethodGet, + fmt.Sprintf("/api/standings/teams/%d/breakdown", teamID), nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error) + } + rows := decodeData[map[string]any](t, env2)["items"].([]any) + out := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + item, ok := row.(map[string]any) + if !ok { + t.Fatalf("明细行不是对象: %+v", row) + } + out = append(out, item) + } + return out +} + +// publishTrailResults 走真实的草稿 + 发布接口发布一批成绩(每次换一个 requestId,不走幂等缓存)。 +func publishTrailResults(t *testing.T, env *testEnv, client *http.Client, eventID int64, tag string, + rows ...map[string]any) { + t.Helper() + if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": eventID, "requestId": tag + "-draft", "results": rows, + }, true); status != http.StatusOK { + t.Fatalf("存草稿应 200,得到 %d %+v", status, env2.Error) + } + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", + map[string]any{"eventId": eventID, "requestId": tag + "-publish"}, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } +} + +// 「发布 → 裁判发现点错、退回草稿改一遍 → 重新发布」之后,明细要能说清这条成绩被改过、 +// 最后一次因为什么改、什么时候 —— 现场观众看到的分要是解释得清的。 +// +// 同时钉住「重新发布会产生新流水行」这件事:修订痕迹跟着 (小项, 报名项) 这条线走, +// 换了流水 id 照样追得到。 +func TestBreakdownCarriesRevisionTrailAfterRepublish(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + publishTrailResults(t, env, client, ids.event, "trail-1", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}) + items := breakdownItems(t, env, client, ids.teamA) + if len(items) != 1 { + t.Fatalf("明细行数 = %d,期望 1", len(items)) + } + firstLedger := items[0]["ledgerId"] + if got := items[0]["revisionCount"]; got != float64(0) { + t.Fatalf("刚发布、没人改过的成绩 revisionCount 应为 0,得到 %v", got) + } + if items[0]["lastRevisionAt"] != nil || items[0]["lastRevisionReason"] != nil { + t.Fatalf("没改过的成绩不该有修订时间 / 原因: %+v", items[0]) + } + + // 裁判发现成绩录错:退回草稿(写理由)→ 改成正确值 → 重新发布。 + var resultID int64 + if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil { + t.Fatalf("读成绩 id: %v", err) + } + if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID), + map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK { + t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error) + } + publishTrailResults(t, env, client, ids.event, "trail-2", + map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s", + "status": "ok", "note": "成绩录入笔误"}) + + items = breakdownItems(t, env, client, ids.teamA) + if len(items) != 1 { + t.Fatalf("明细行数 = %d,期望 1", len(items)) + } + item := items[0] + if item["ledgerId"] == firstLedger { + t.Fatalf("重新发布应作废旧流水、写一条新流水(旧 id %v),得到 %v", firstLedger, item["ledgerId"]) + } + if got := item["revisionCount"]; got != float64(1) { + t.Fatalf("改过一次的成绩 revisionCount 应为 1,得到 %v(%+v)", got, item) + } + if got := item["lastRevisionReason"]; got != "成绩录入笔误" { + t.Fatalf("最后一次修订原因 = %v,期望 成绩录入笔误", got) + } + if at, ok := item["lastRevisionAt"].(string); !ok || at == "" { + t.Fatalf("最后一次修订时间应下发,得到 %v", item["lastRevisionAt"]) + } + if got := item["lastRevisedBy"]; got != "recorder" { + t.Fatalf("最后一次修订的操作人 = %v,期望 recorder", got) + } + + // 未登录访客(大屏读的就是这个接口)拿得到「被改过、什么时候、为什么」, + // 但拿不到「是谁点错的」—— 那是裁判自己看的账,与 /api/revisions 只对 recorder 开放同一口径。 + guest, _ := env.client() + items = breakdownItems(t, env, guest, ids.teamA) + if got := items[0]["revisionCount"]; got != float64(1) { + t.Fatalf("访客也该看到修订次数,得到 %v", got) + } + if got := items[0]["lastRevisionReason"]; got != "成绩录入笔误" { + t.Fatalf("访客也该看到修订原因,得到 %v", got) + } + if got := items[0]["lastRevisedBy"]; got != nil { + t.Fatalf("访客不该拿到操作人姓名,得到 %v", got) + } +} + +// 一次都没改过的成绩不能标成「已修订」——这是最要防的误报。 +// +// 两次发布之间引擎会重算名次、把 state / verified_at 重写一遍,同小项别人的成绩也在变; +// 这些都不该让一条没动过的成绩挂上角标。 +func TestBreakdownKeepsUnchangedResultUnrevised(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + publishTrailResults(t, env, client, ids.event, "trail-1", + 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"}) + // A 单位这条成绩一个字没改,只是把别人的成绩改了一次再发布(名次会跟着重算)。 + var resultID int64 + if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryB).Scan(&resultID); err != nil { + t.Fatalf("读成绩 id: %v", err) + } + if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID), + map[string]any{"reason": "计时有误"}, true); status != http.StatusOK { + t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error) + } + publishTrailResults(t, env, client, ids.event, "trail-2", + map[string]any{"entryId": ids.entryB, "resultValue": 11.60, "resultText": "11.60", "unit": "s", "status": "ok"}) + + items := breakdownItems(t, env, client, ids.teamA) + if len(items) != 1 { + t.Fatalf("明细行数 = %d,期望 1", len(items)) + } + for field, want := range map[string]any{ + "revisionCount": float64(0), + "lastRevisionAt": nil, + "lastRevisionReason": nil, + "lastRevisedBy": nil, + } { + if got := items[0][field]; got != want { + t.Fatalf("没改过的成绩 %s 应为 %v,得到 %v(%+v)", field, want, got, items[0]) + } + } +} + +// 破纪录加分这张流水是站在成绩旁边的另一笔分:它不继承成绩的修订痕迹。 +// 挂上去等于对外说「那笔加分被改过」,而现场改的是成绩 —— 那是误报。 +func TestBreakdownRecordBonusDoesNotInheritTrail(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + publishTrailResults(t, env, client, ids.event, "trail-1", + map[string]any{"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}) + var resultID int64 + if err := env.ev.QueryRow(`SELECT id FROM results WHERE entry_id = ?`, ids.entryA).Scan(&resultID); err != nil { + t.Fatalf("读成绩 id: %v", err) + } + if status, env2 := env.do(client, http.MethodPost, fmt.Sprintf("/api/results/%d/reopen", resultID), + map[string]any{"reason": "成绩录入笔误"}, true); status != http.StatusOK { + t.Fatalf("退回草稿应 200,得到 %d %+v", status, env2.Error) + } + publishTrailResults(t, env, client, ids.event, "trail-2", + map[string]any{"entryId": ids.entryA, "resultValue": 11.42, "resultText": "11.42", "unit": "s", "status": "ok"}) + + // 同一报名项再挂一条破纪录加分流水(与 writeLedgerTx 产出的形态一致:带 entry_id / result_id)。 + env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points, count_to_team) + VALUES (?, ?, ?, ?, ?, 'record_bonus', 1, 3, 1)`, + ids.teamA, ids.athleteA, ids.event, ids.entryA, resultID) + + items := breakdownItems(t, env, client, ids.teamA) + if len(items) != 2 { + t.Fatalf("明细行数 = %d,期望 2(成绩 + 破纪录加分)", len(items)) + } + for _, item := range items { + switch item["sourceType"] { + case "result": + if got := item["revisionCount"]; got != float64(1) { + t.Fatalf("被改过的成绩应报 1 次修订,得到 %v(%+v)", got, item) + } + case "record_bonus": + if got := item["revisionCount"]; got != nil { + t.Fatalf("破纪录加分不继承成绩的修订痕迹,得到 %v(%+v)", got, item) + } + default: + t.Fatalf("意外的来源类型: %+v", item) + } + } +} + +// 聚合流水(按队内平均)与人工调整没有「一条成绩」可追:整组字段留空,不许猜一个数字出来。 +func TestBreakdownAggregateAndAdjustmentHaveNoTrail(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points, count_to_team) + VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event) + env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points, count_to_team) + VALUES (?, NULL, NULL, NULL, NULL, 'adjustment', 1, 5, 1)`, ids.teamA) + + items := breakdownItems(t, env, client, ids.teamA) + if len(items) != 2 { + t.Fatalf("明细行数 = %d,期望 2", len(items)) + } + for _, item := range items { + if got := item["revisionCount"]; got != nil { + t.Fatalf("追不出来源的流水 revisionCount 应为 null,得到 %v(%+v)", got, item) + } + } +} diff --git a/server/internal/httpapi/channel_aggregate_mode_test.go b/server/internal/httpapi/channel_aggregate_mode_test.go new file mode 100644 index 0000000..8e150ce --- /dev/null +++ b/server/internal/httpapi/channel_aggregate_mode_test.go @@ -0,0 +1,157 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// 聚合口径(通道的 aggregateMode:avg 平均 / sum 总和)的 REST 契约与端到端效果。 +// +// 契约: +// - 请求体写 sum 能存下、读回来还是 sum; +// - 不写该字段(旧客户端 / 老配置)落成缺省 avg,响应里不下发——老数据的响应体一个字节都不变; +// - 认不出的取值 400 拦下,而不是静默按 avg 处理(拼错的配置会静默改口径,现场看不出来)。 +// +// 效果:现场切换口径后重算,单位总分(此处看流水)必须按新口径走。 +func TestChannelAggregateModeSwitch(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + client := recorderClient(t, env) + + var teamProfile int64 + if err := env.ev.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil { + t.Fatalf("读取团体积分模板: %v", err) + } + + // 团体引体向上:A 队 2 人(10 + 1)、B 队 1 人(9)。 + // 总数:A 11 > B 9;平均:A 5.5 < B 9 —— 两种口径的名次正好互换。 + ev := env.insert(t, `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'TEAM_PULLUP', '团体引体向上', 'individual_team', 'auto', 'count', 'desc', 1, 1, 'ongoing')`, ids.sport) + cat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, ev) + athA1 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('甲', '男', ?, 1)`, ids.teamA) + athA2 := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES ('乙', '男', ?, 1)`, ids.teamA) + entryA1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA1) + entryA2 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamA, athA2) + entryB1 := env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) VALUES (?, ?, ?, ?, 'registered')`, ev, cat, ids.teamB, ids.athB) + + chans := fmt.Sprintf("/api/events/%d/channels", ev) + + // setMode 通过 REST 写通道配置;mode 为空串表示不带该字段(模拟旧客户端)。 + setMode := func(mode string) []map[string]any { + t.Helper() + channel := map[string]any{"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1} + if mode != "" { + channel["aggregateMode"] = mode + } + status, resp := env.do(client, http.MethodPut, chans, map[string]any{"channels": []map[string]any{channel}}, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("写通道(aggregateMode=%q)应 200,得到 %d %+v", mode, status, resp.Error) + } + return decodeData[[]map[string]any](t, resp) + } + storedMode := func() string { + t.Helper() + var mode string + if err := env.ev.QueryRow(`SELECT aggregate_mode FROM event_score_channels WHERE event_id = ? AND basis = 'aggregate'`, ev).Scan(&mode); err != nil { + t.Fatalf("读取落库的聚合口径: %v", err) + } + return mode + } + listedMode := func() (string, bool) { + t.Helper() + status, resp := env.do(client, http.MethodGet, chans, nil, false) + if status != http.StatusOK || !resp.OK { + t.Fatalf("读通道列表应 200,得到 %d %+v", status, resp.Error) + } + got := decodeData[[]map[string]any](t, resp) + raw, ok := got[0]["aggregateMode"] + if !ok { + return "", false + } + return raw.(string), true + } + teamLedger := func() map[int64]float64 { + t.Helper() + rows, err := env.ev.Query(`SELECT team_id, SUM(points) FROM score_ledger + WHERE voided = 0 AND event_id = ? GROUP BY team_id`, ev) + if err != nil { + t.Fatalf("统计单位流水: %v", err) + } + defer func() { _ = rows.Close() }() + out := make(map[int64]float64) + for rows.Next() { + var teamID int64 + var points float64 + if err := rows.Scan(&teamID, &points); err != nil { + t.Fatalf("扫描流水: %v", err) + } + out[teamID] = points + } + return out + } + + // 1) 缺省:不写 aggregateMode,落库是 avg,响应里也不下发该字段(老数据响应体不变)。 + channels := setMode("") + if _, ok := channels[0]["aggregateMode"]; ok { + t.Errorf("缺省口径(avg)不应出现在响应里,得到 %+v", channels[0]) + } + if got := storedMode(); got != "avg" { + t.Fatalf("不带 aggregateMode 落库 = %q,期望 avg", got) + } + + // 录成绩并发布:B 队平均 9 第一(15 分),A 队平均 5.5 第二(10 分)。 + status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ev, + "results": []map[string]any{ + {"entryId": entryA1, "resultValue": 10, "unit": "个", "status": "ok"}, + {"entryId": entryA2, "resultValue": 1, "unit": "个", "status": "ok"}, + {"entryId": entryB1, "resultValue": 9, "unit": "个", "status": "ok"}, + }, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, resp.Error) + } + status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{"eventId": ev}, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, resp.Error) + } + if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 { + t.Fatalf("avg 口径下单位流水 = %+v,期望 A=10 B=15", got) + } + + // 2) 现场切成总和:改通道配置即触发重算,名次互换(A 11 > B 9)。 + channels = setMode("sum") + if channels[0]["aggregateMode"] != "sum" { + t.Errorf("sum 口径应原样读回,得到 %+v", channels[0]) + } + if got := storedMode(); got != "sum" { + t.Fatalf("sum 口径落库 = %q,期望 sum", got) + } + if got, ok := listedMode(); !ok || got != "sum" { + t.Fatalf("通道列表应带 aggregateMode=sum,得到 %q(下发=%v)", got, ok) + } + if got := teamLedger(); got[ids.teamA] != 15 || got[ids.teamB] != 10 { + t.Fatalf("sum 口径下单位流水 = %+v,期望 A=15 B=10", got) + } + + // 3) 切回缺省:不带该字段保存后,口径与落到 avg 的榜都要回来。 + setMode("") + if got := teamLedger(); got[ids.teamB] != 15 || got[ids.teamA] != 10 { + t.Fatalf("切回 avg 后单位流水 = %+v,期望 A=10 B=15", got) + } + + // 4) 认不出的取值 400,且不能把已配好的口径改坏。 + status, resp = env.do(client, http.MethodPut, chans, map[string]any{ + "channels": []map[string]any{ + {"scope": "team", "basis": "aggregate", "profileId": teamProfile, "sortOrder": 1, "aggregateMode": "average"}, + }, + }, true) + if status != http.StatusBadRequest || resp.Error == nil || !strings.Contains(resp.Error.Message, "aggregateMode") { + t.Fatalf("非法聚合口径应 400 并点明 aggregateMode,得到 %d %+v", status, resp.Error) + } + if got := storedMode(); got != "avg" { + t.Fatalf("被拒的请求不应改动落库口径,得到 %q", got) + } +} diff --git a/server/internal/httpapi/dto.go b/server/internal/httpapi/dto.go index d916dce..79d986f 100644 --- a/server/internal/httpapi/dto.go +++ b/server/internal/httpapi/dto.go @@ -56,6 +56,10 @@ type channelDTO struct { Basis string `json:"basis"` ProfileID int64 `json:"profileId"` SortOrder int `json:"sortOrder"` + // AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均 / sum 按成员总和。 + // 请求体里缺省(空串)按 avg 处理,不带该字段的旧客户端保存通道时口径不变。 + // 响应里只下发非缺省值(sum);缺省(avg)不下发,老数据的响应体因此一个字节都不变。 + AggregateMode string `json:"aggregateMode,omitempty"` // CountToTeam 决定该通道产出的流水是否计入单位总分;请求体里缺省(null)按 true 处理, // 「个人+团体」项目的个人通道传 false。响应里始终是具体值。 CountToTeam *bool `json:"countToTeam"` @@ -188,6 +192,16 @@ type breakdownItemDTO struct { ResultText *string `json:"resultText"` Points float64 `json:"points"` CreatedAt string `json:"createdAt"` + + // 修订痕迹(口径见 internal/revisions):这条成绩发布之后被改过几次、最后一次因为什么改、什么时候。 + // + // 三个字段一起看:RevisionCount 为 nil 表示**追不出来**(没有版本基线),为 0 表示追出来了、 + // 确实没改过;前端只在 > 0 时显示「已修订 ×N」角标。宁可为空也不误报。 + // LastRevisionReason / LastRevisedBy 都可能为 nil:现场没写原因、操作人已删号时就是空的。 + RevisionCount *int `json:"revisionCount"` + LastRevisionAt *string `json:"lastRevisionAt"` + LastRevisionReason *string `json:"lastRevisionReason"` + LastRevisedBy *string `json:"lastRevisedBy"` } type teamBreakdownDTO struct { diff --git a/server/internal/httpapi/dual_channel_publish_test.go b/server/internal/httpapi/dual_channel_publish_test.go new file mode 100644 index 0000000..7730461 --- /dev/null +++ b/server/internal/httpapi/dual_channel_publish_test.go @@ -0,0 +1,200 @@ +package httpapi_test + +import ( + "database/sql" + "fmt" + "net/http" + "testing" +) + +// 本文件钉住工单 A-1 / A-2 两条「同一份数据发布两次结果不同」的失败场景。 +// 两者的共同根因是 results.rank 一列身兼两职——它既是自动算出的个人名次的回写位, +// 又是人工名次的录入位: +// +// A-1:团体报名项(athlete_id 为空)的 results.rank 被个人通道自动回写, +// 下一次发布时又被团体手工通道的兜底读成「团体名次」,团体分凭空多出一份 +// (发布 #1 得 10/8/6,发布 #2 变 25/18/14); +// A-2:[athlete,result] 与 [athlete,manual] 共存时,自动名次覆盖人工名次, +// 人工通道的输入被就地改写 → 第二次发布才算对(发布 #1 得 18 分、#2 得 20 分)。 + +// seedDualChannelTeamEvent 建工单 A-1 的形态:team 类型小项 + 3 条团体报名项 +// (athlete_id 为空,正是《报名名单导入规范》要求的团体项目填法)+ 双通道 +// [athlete/result, team/manual](自带规则包曾给 TEAM_PULLUP 配的就是这两条)。 +func seedDualChannelTeamEvent(t *testing.T, env *testEnv, ids seedIDs) (eventID, categoryID int64, entries []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 (?, 'EMIX', '混合配置试验', 'team', 'auto', 'count', 'desc', 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 (?, 'athlete', 'result', ?, 0)`, eventID, profileIDByCode(t, env, "individual_top6")) + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'team', 'manual', ?, 1)`, eventID, profileIDByCode(t, env, "team_top6")) + + teamC := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) + VALUES ('C', '第三分公司', '三司', 2, 1)`) + for _, teamID := range []int64{ids.teamA, ids.teamB, teamC} { + entries = append(entries, env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, NULL, 'registered')`, eventID, categoryID, teamID)) + } + return eventID, categoryID, entries +} + +// resultRank 读一条成绩的手工名次(NULL 与 0 都表示「没填」)。 +func resultRank(t *testing.T, env *testEnv, entryID int64) int { + t.Helper() + var rank sql.NullInt64 + if err := env.ev.QueryRow(`SELECT rank FROM results WHERE entry_id = ?`, entryID).Scan(&rank); err != nil { + t.Fatalf("读报名项 %d 的名次: %v", entryID, err) + } + if !rank.Valid { + return 0 + } + return int(rank.Int64) +} + +// resultRanksDump 导出某小项每条成绩的手工名次,用于断言「自动名次有没有被写回 results.rank」。 +func resultRanksDump(t *testing.T, env *testEnv, eventID int64) string { + t.Helper() + return tableDump(t, env, fmt.Sprintf(`SELECT entry_id, COALESCE(rank, 0) AS rank, COALESCE(result_value, 0) AS value + FROM results WHERE event_id = %d ORDER BY entry_id`, eventID)) +} + +// A-1:团体报名项 + 双通道,连发 1~3 次必须逐字节一致。 +func TestPublishTwiceWithTeamEntryAndDualChannelIsIdempotent(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, _, entries := seedDualChannelTeamEvent(t, env, ids) + + publishResults(t, env, client, eventID, "a1-p1", + map[string]any{"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}, + map[string]any{"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"}, + map[string]any{"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"}, + ) + + // 团体报名项的 results.rank 是「单位名次」的录入位(团体手工通道的兜底来源), + // 个人通道算出的自动名次不许写进去——写进去第二次发布就会被当成团体名次再发一遍分。 + if n := countRows(t, env, `SELECT COUNT(*) FROM results WHERE event_id = ? AND rank IS NOT NULL`, eventID); n != 0 { + t.Fatalf("团体报名项的 results.rank 被自动名次回写,得到 %d 行:\n%s", n, resultRanksDump(t, env, eventID)) + } + if total := activeLedgerTotal(t, env); total != 24 { + t.Fatalf("首次发布应只有个人通道的 10+8+6=24 分(团体名次未录),得到 %v", total) + } + + ledgerFirst := ledgerActiveDump(t, env) + ranksFirst := channelRanksDump(t, env) + for _, tag := range []string{"a1-p2", "a1-p3"} { + publishEvent(t, env, client, eventID, tag) + if total := activeLedgerTotal(t, env); total != 24 { + t.Fatalf("%s:重复发布后合计应为 24(团体手工通道没有名次可发),得到 %v", tag, total) + } + if got := ledgerActiveDump(t, env); got != ledgerFirst { + t.Fatalf("%s:同一份数据重复发布流水应完全一致:\n第一次 %s\n本次 %s", tag, ledgerFirst, got) + } + if got := channelRanksDump(t, env); got != ranksFirst { + t.Fatalf("%s:同一份数据重复发布名次应完全一致:\n第一次 %s\n本次 %s", tag, ranksFirst, got) + } + } +} + +// A-2:同一 result 同时被「自动名次通道」与「人工名次通道」消费时,人工名次不被覆盖。 +func TestManualAthleteRankNotOverwrittenByComputedRank(t *testing.T) { + env, ids, client := recorderWithSeed(t) + // 在既有的 [athlete,result] 之外再加一条 [athlete,manual](唯一约束按 scope+basis 允许共存)。 + env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) + VALUES (?, 'athlete', 'manual', ?, 1)`, ids.event, profileIDByCode(t, env, "individual_top6")) + + // 成绩 30 / 29(compare=asc:数值小的靠前)→ 自动名次本该是 乙1 第 1、甲1 第 2; + // 人工名次反着填(甲1 第 1、乙1 第 2),两次发布都必须按人工名次算。 + publishResults(t, env, client, ids.event, "a2-p1", + map[string]any{"entryId": ids.entryA, "resultValue": 30, "resultText": "30", "unit": "次", "status": "ok", "rank": 1}, + map[string]any{"entryId": ids.entryB, "resultValue": 29, "resultText": "29", "unit": "次", "status": "ok", "rank": 2}, + ) + + if rankA, rankB := resultRank(t, env, ids.entryA), resultRank(t, env, ids.entryB); rankA != 1 || rankB != 2 { + t.Fatalf("人工名次被自动名次覆盖:甲1=%d、乙1=%d,期望 1 / 2", rankA, rankB) + } + // 自动通道 8/10 + 人工通道 10/8:A 18 分、B 18 分。 + if got := teamPoints(t, env, client, ids.teamA); got != 18 { + t.Fatalf("首次发布后 A 应为 18 分(自动 8 + 人工 10),得到 %v", got) + } + + ledgerFirst := ledgerActiveDump(t, env) + ranksFirst := channelRanksDump(t, env) + for _, tag := range []string{"a2-p2", "a2-p3"} { + publishEvent(t, env, client, ids.event, tag) + if rankA := resultRank(t, env, ids.entryA); rankA != 1 { + t.Fatalf("%s:人工名次又被改写,甲1=%d,期望 1", tag, rankA) + } + if got := teamPoints(t, env, client, ids.teamA); got != 18 { + t.Fatalf("%s:重复发布后 A 应仍为 18 分,得到 %v", tag, got) + } + if got := ledgerActiveDump(t, env); got != ledgerFirst { + t.Fatalf("%s:同一份数据重复发布流水应完全一致:\n第一次 %s\n本次 %s", tag, ledgerFirst, got) + } + if got := channelRanksDump(t, env); got != ranksFirst { + t.Fatalf("%s:同一份数据重复发布名次应完全一致:\n第一次 %s\n本次 %s", tag, ranksFirst, got) + } + } +} + +// 修 A-1 时不能把「team + basis=result」这条路的团体名次一起修断: +// 纯团体项目的团体名次(从 channel_ranks 读)必须照旧产出。 +func TestTeamResultChannelStillRanksTeams(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, categoryID, teamEntry := seedTeamEvent(t, env, ids) + + publishResults(t, env, client, eventID, "team-result", + map[string]any{"entryId": teamEntry, "resultValue": 45.20, "resultText": "45.20", "unit": "s", "status": "ok"}, + ) + if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks + WHERE event_id = ? AND category_id = ? AND scope = 'team' AND basis = 'result' AND rank = 1`, + eventID, categoryID); n != 1 { + t.Fatalf("纯团体项目(team + basis=result)应产出团体名次,得到 %d 行:%s", n, channelRanksDump(t, env)) + } + if got := teamPoints(t, env, client, ids.teamA); got != 15 { + t.Fatalf("纯团体项目 A 应得 15 分,得到 %v", got) + } + publishEvent(t, env, client, eventID, "team-result-again") + if got := teamPoints(t, env, client, ids.teamA); got != 15 { + t.Fatalf("重复发布后 A 应仍为 15 分,得到 %v", got) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 1 { + t.Fatalf("纯团体项目应只有 1 条流水,得到 %d 条:%s", n, ledgerActiveDump(t, env)) + } +} + +// 团队手工名次(channel_manual_ranks)与团体报名项兜底两条来源并存时,以录入的团体名次为准; +// 个人通道的自动名次不得插进团体名次的来源。 +func TestTeamManualRanksSurviveComputedRankWriteback(t *testing.T) { + env, ids, client := recorderWithSeed(t) + eventID, categoryID, entries := seedDualChannelTeamEvent(t, env, ids) + + if status, env2 := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{ + {"teamId": ids.teamA, "rank": 1}, + {"teamId": ids.teamB, "rank": 2}, + }); status != http.StatusOK { + t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error) + } + + publishResults(t, env, client, eventID, "a1-manual", + map[string]any{"entryId": entries[0], "resultValue": 90, "resultText": "90", "unit": "次", "status": "ok"}, + map[string]any{"entryId": entries[1], "resultValue": 80, "resultText": "80", "unit": "次", "status": "ok"}, + map[string]any{"entryId": entries[2], "resultValue": 70, "resultText": "70", "unit": "次", "status": "ok"}, + ) + // 个人通道 10/8/6 + 团体手工名次 15/10。 + if total := activeLedgerTotal(t, env); total != 24+25 { + t.Fatalf("首次发布合计应为 49(24 个人 + 25 团体),得到 %v", total) + } + ledgerFirst := ledgerActiveDump(t, env) + publishEvent(t, env, client, eventID, "a1-manual-again") + if total := activeLedgerTotal(t, env); total != 49 { + t.Fatalf("重复发布后合计应仍为 49,得到 %v", total) + } + if got := ledgerActiveDump(t, env); got != ledgerFirst { + t.Fatalf("流水应完全一致:\n第一次 %s\n本次 %s", ledgerFirst, got) + } + if got := teamPoints(t, env, client, ids.teamA); got != 10+15 { + t.Fatalf("A 应为个人 10 + 团体 15 = 25 分,得到 %v", got) + } +} diff --git a/server/internal/httpapi/event_delete_ledger_test.go b/server/internal/httpapi/event_delete_ledger_test.go new file mode 100644 index 0000000..ba8c59d --- /dev/null +++ b/server/internal/httpapi/event_delete_ledger_test.go @@ -0,0 +1,142 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "testing" +) + +// 本文件钉住审查 01 的 P0:删除小项必须让它的积分流水一起离榜。 +// +// 现场形态是「球类那种」小项——纯团体、名次按单位直接录进 channel_manual_ranks, +// 一条报名项都没有。这种小项正是唯一能被 DELETE /api/events/{id} 删掉、却又在榜上有分的东西: +// 有报名数据的小项会被守卫拦下(见 TestDeleteEventStillBlockedByEntries)。 + +// seedTeamManualOnlyEvent 建一个「只有团体名次、没有报名数据」的小项:纯团体 + 一个组别 + +// 一条 scope=team/basis=manual 通道,不建任何 entries(有 entries 就删不掉了)。 +func seedTeamManualOnlyEvent(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 (?, 'BASKET', '篮球', 'team', 'manual', 'score', 'desc', 1, 1, 'ongoing')`, ids.sport) + categoryID = env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '男子', 1, 0)`, eventID) + addTeamManualChannel(t, env, eventID) + return eventID, categoryID +} + +// standingsRaw 取积分榜原始响应体(逐字节比较用,比逐字段断言更严格)。 +func standingsRaw(t *testing.T, env *testEnv, client *http.Client) string { + t.Helper() + status, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读积分榜应 200,得到 %d %+v", status, env2.Error) + } + return string(env2.Data) +} + +// P0 回归:删掉「只有团体名次、没有报名数据」的小项后,它的流水必须被作废、榜单必须跟着变。 +// +// 修之前:DELETE 返回 200,但流水只是被外键置空 event_id(voided 仍是 0), +// 分数永久留在榜上,此后任何按 event_id 的重算都打不到这些行(审查 01 的实测证据)。 +func TestDeleteEventVoidsTeamManualLedgerAndRepushesStandings(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + // 基线:还没有这个小项时的榜单,删除后必须逐字节回到这里。 + baseline := standingsRaw(t, env, client) + + eventID, categoryID := seedTeamManualOnlyEvent(t, env, ids) + // 名次是现场直接按单位录的,没有报名项也没有成绩行;录入即重算,分当场进榜。 + if status, env2 := putTeamRanks(t, env, client, eventID, categoryID, []map[string]any{ + {"teamId": ids.teamA, "rank": 1}, + {"teamId": ids.teamB, "rank": 2}, + }); status != http.StatusOK || !env2.OK { + t.Fatalf("写团体名次应 200,得到 %d %+v", status, env2.Error) + } + + // 前置条件:删除前这个小项确实在榜上有分,否则这条用例证明不了任何事。 + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id = ? AND voided = 0`, eventID); n != 2 { + t.Fatalf("前置条件:小项应有 2 条有效流水,得到 %d 条:%s", n, ledgerActiveDump(t, env)) + } + if got := teamPoints(t, env, client, ids.teamA); got != 15 { + t.Fatalf("前置条件:A 总分应为 15,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 10 { + t.Fatalf("前置条件:B 总分应为 10,得到 %v", got) + } + if gold := teamGold(t, env, client, ids.teamA); gold != 1 { + t.Fatalf("前置条件:A 应有 1 金,得到 %d", gold) + } + standingsBefore := standingsChangeRows(t, env) + + status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", eventID), nil, true) + if status != http.StatusOK || !env2.OK { + t.Fatalf("删除小项应 200,得到 %d %+v", status, env2.Error) + } + + // 1) 流水是作废(保留行 + 作废原因),不是没有痕迹地消失。 + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE voided = 0`); n != 0 { + t.Fatalf("删除后不该还有有效流水(孤儿会永久留在榜上),得到 %d 条:%s", n, ledgerActiveDump(t, env)) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger + WHERE voided = 1 AND void_reason = '小项删除,本小项积分流水一并作废'`); n != 2 { + t.Fatalf("应留下 2 条带作废原因的流水作为审计痕迹,得到 %d 条", n) + } + // 作废行此刻的 event_id 已被外键置空:能作废成功本身就说明 UPDATE 排在 DELETE FROM events 之前, + // 顺序反了这里就是 0 条(上面两条断言会先失败)。 + if n := countRows(t, env, `SELECT COUNT(*) FROM score_ledger WHERE event_id IS NULL AND voided = 1`); n != 2 { + t.Fatalf("作废行应保留下来(event_id 置空),得到 %d 条", n) + } + + // 2) 榜单不再受它影响:分数、奖牌、名次全部回到建这个小项之前的形态。 + if after := standingsRaw(t, env, client); after != baseline { + t.Fatalf("删除后榜单应逐字节回到该小项存在之前:\n之前 %s\n之后 %s", baseline, after) + } + if got := teamPoints(t, env, client, ids.teamA); got != 0 { + t.Fatalf("删除后 A 不该再有分,得到 %v", got) + } + if got := teamPoints(t, env, client, ids.teamB); got != 0 { + t.Fatalf("删除后 B 不该再有分,得到 %v", got) + } + if gold := teamGold(t, env, client, ids.teamA); gold != 0 { + t.Fatalf("删除后 A 不该再有金牌,得到 %d", gold) + } + + // 3) 派生数据随之消失(外键级联),下钻不会再列出一堆查不到出处的名次。 + if n := countRows(t, env, `SELECT COUNT(*) FROM channel_ranks WHERE event_id = ?`, eventID); n != 0 { + t.Fatalf("删除小项后 channel_ranks 应随外键级联清空,得到 %d 行", n) + } + if n := countRows(t, env, `SELECT COUNT(*) FROM channel_manual_ranks WHERE event_id = ?`, eventID); n != 0 { + t.Fatalf("删除小项后 channel_manual_ranks 应随外键级联清空,得到 %d 行", n) + } + + // 4) 榜单变了就必须推给大屏:只有 events void 一条信号时,大屏会一直停在旧分上。 + if after := standingsChangeRows(t, env); after <= standingsBefore { + t.Fatalf("删除小项应推一次 standings:之前 %d 条,之后 %d 条", standingsBefore, after) + } + if n := changeLogCount(t, env, "events", "void"); n != 1 { + t.Fatalf("删除小项应照旧写 1 条 events void 变更,得到 %d 条", n) + } +} + +// 守卫没被改动:有报名数据的小项仍然删不掉,且被拦下时一条流水都不许作废。 +func TestDeleteEventStillBlockedByEntries(t *testing.T) { + env, ids, client := recorderWithSeed(t) + publishResults(t, env, client, ids.event, "guard", + 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"}, + ) + ledgerBefore := ledgerActiveDump(t, env) + standingsBefore := standingsRaw(t, env, client) + + status, env2 := env.do(client, http.MethodDelete, fmt.Sprintf("/api/events/%d", ids.event), nil, true) + if status != http.StatusConflict || env2.Error == nil || env2.Error.Code != "CONFLICT" { + t.Fatalf("有报名数据的小项应 409 且报错可读,得到 %d %+v", status, env2.Error) + } + if after := ledgerActiveDump(t, env); after != ledgerBefore { + t.Fatalf("被拦下的删除不该动作何流水:\n之前 %s\n之后 %s", ledgerBefore, after) + } + if after := standingsRaw(t, env, client); after != standingsBefore { + t.Fatalf("被拦下的删除不该动榜单:\n之前 %s\n之后 %s", standingsBefore, after) + } +} diff --git a/server/internal/httpapi/last_place_zero_e2e_test.go b/server/internal/httpapi/last_place_zero_e2e_test.go new file mode 100644 index 0000000..9d129aa --- /dev/null +++ b/server/internal/httpapi/last_place_zero_e2e_test.go @@ -0,0 +1,61 @@ +package httpapi_test + +import ( + "net/http" + "testing" +) + +// 末位归零的端到端回归(任务 F):现场模板勾着 last_place_zero=1,7 人里三人并列第 5 +// (占 5/6/7)时,第 5 名的 2 分与第 6 名的 1 分不该被一起抹掉——修复前这里是 10/8/6/3/**0/0/0**。 +// 走的是真实接口:存草稿 → 发布 → 写 score_ledger → 积分榜,覆盖引擎之外的那一段。 +func TestLastPlaceZeroTieCrossingKeepsEarnedPointsE2E(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 现场模板勾着「末位归零」。 + env.exec(t, `UPDATE scoring_profiles SET last_place_zero = 1 WHERE code = 'individual_top6'`) + + // 补 5 个单位 + 7 条报名项(A、B 用 seed 的)。 + entries := []int64{ids.entryA, ids.entryB} + codes := []string{"A", "B", "C", "D", "E", "F", "G"} + for i := 2; i < 7; i++ { + teamID := env.insert(t, `INSERT INTO teams (code, name, short_name, sort_order, active) VALUES (?, ?, ?, ?, 1)`, + codes[i], "单位"+codes[i], codes[i], i) + athleteID := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, "选手"+codes[i], 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) + entries = append(entries, entryID) + } + + // 成绩 1,2,3,4,5,5,5 → 名次 1,2,3,4,5,5,5(三人并列第 5,占 5/6/7)。 + values := []float64{1, 2, 3, 4, 5, 5, 5} + results := make([]map[string]any, 0, len(entries)) + for i, entryID := range entries { + results = append(results, map[string]any{"entryId": entryID, "resultValue": values[i], "status": "ok"}) + } + if status, env2 := env.do(client, http.MethodPut, "/api/results/draft", + map[string]any{"eventId": ids.event, "results": results}, true); status != http.StatusOK || !env2.OK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, env2.Error) + } + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", + map[string]any{"eventId": ids.event}, true); status != http.StatusOK || !env2.OK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } + + _, env2 := env.do(client, http.MethodGet, "/api/standings", nil, false) + points := map[string]float64{} + for _, row := range decodeData[[]map[string]any](t, env2) { + points[row["teamCode"].(string)] = row["totalPoints"].(float64) + } + // 个人前六名表 10/8/6/3/2/1:三个并列第 5 的单位都拿第 5 名的 2 分,末位那一档(第 7 名) + // 本来就没配分,所以这里看不到任何「被归零」的痕迹。 + want := map[string]float64{"A": 10, "B": 8, "C": 6, "D": 3, "E": 2, "F": 2, "G": 2} + for code, w := range want { + if points[code] != w { + t.Errorf("单位 %s 总分 = %v,期望 %v(完整:%v)", code, points[code], w, points) + } + } +} diff --git a/server/internal/httpapi/profile_award_ranks_overflow_test.go b/server/internal/httpapi/profile_award_ranks_overflow_test.go new file mode 100644 index 0000000..2709548 --- /dev/null +++ b/server/internal/httpapi/profile_award_ranks_overflow_test.go @@ -0,0 +1,250 @@ +package httpapi_test + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// 本文件钉住工单 E 的失败场景:「分值表档位」与「录取档位数(awardRanks)」不一致时的静默截断。 +// +// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints), +// 而保存模板(repo.go 的 replaceProfileRules)原先只看名次是否重复、是否 >= 1, +// 从不看两边的档位数是否对得上:配到第 7 档、录取名次还留着 6 时,7 档照样落库—— +// 模板页上第 7 档写着 2 分,榜上恒为 0 分,界面上看不出任何异常(审查 01 P1)。 +// 现在保存与规则包导入两条路都在写入前拒收,并给出同一句人话说明。 +// awardRanks = 0 表示不按名次封顶(既有语义):必须继续放行,且第 7 档真能得分。 + +// sevenRankRules 是配到第 7 档的分值表(第 7 档 2 分);与前六档分值互不相同,便于按分数定位名次。 +func sevenRankRules() []map[string]any { + return []map[string]any{ + {"rank": 1, "points": 10}, {"rank": 2, "points": 8}, {"rank": 3, "points": 6}, + {"rank": 4, "points": 5}, {"rank": 5, "points": 4}, {"rank": 6, "points": 3}, + {"rank": 7, "points": 2}, + } +} + +// sixRankRules 是种子模板 individual_top6 的现行六档分值表,用于对照「正好覆盖」。 +func sixRankRules() []map[string]any { + return sevenRankRules()[:6] +} + +// addIndividualEntries 给种子小项补 n 个运动员与报名项,返回新增的报名项 id +// (个人项目一人一条,沿用种子里的单位与组别)。 +func addIndividualEntries(t *testing.T, env *testEnv, ids seedIDs, n int) []int64 { + t.Helper() + out := make([]int64, 0, n) + for i := 0; i < n; i++ { + athlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) VALUES (?, '男', ?, 1)`, + fmt.Sprintf("补录%d", i), ids.teamA) + out = append(out, env.insert(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, ids.category, ids.teamA, athlete)) + } + return out +} + +// 甲:分值表配到第 7 档、录取名次只到 6 → 新建与更新都必须拒绝,且库里一个字节都不动。 +func TestProfileAwardRanksMustCoverRuleRanks(t *testing.T) { + env, _, client := recorderWithSeed(t) + + // 新建:改前是 200 + 7 档全落库(第 7 档永远算 0 分)。 + status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ + "code": "P-TRUNC", "name": "录取六档配到七档", "awardRanks": 6, "rules": sevenRankRules(), + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("awardRanks=6 + 第 7 档应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) + } + // 文案要能让现场自己改:点明超出的档位、当前录取名次、以及「0 表示不封顶」这个出口。 + for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} { + if !strings.Contains(resp.Error.Message, want) { + t.Fatalf("400 文案里缺少 %q:%q", want, resp.Error.Message) + } + } + if n := countRows(t, env, `SELECT COUNT(*) FROM scoring_profiles WHERE code = 'P-TRUNC'`); n != 0 { + t.Fatalf("被拒的模板不该落库,得到 %d 行", n) + } + + // 更新:带上同一份七档分值表,同样拒绝,且模板姓名次表都不许改。 + before := profileRuleRanks(t, env, client, 1) + status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{ + "code": "individual_top6", "name": "个人项目前六名", "awardRanks": 6, "rules": sevenRankRules(), + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("更新成 7 档应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "第 7 档") { + t.Fatalf("更新路径的 400 文案也应点明超出的档位,得到 %q", resp.Error.Message) + } + if after := profileRuleRanks(t, env, client, 1); len(after) != len(before) { + t.Fatalf("被拒的更新不该改名次表,得到 %v → %v", before, after) + } + + // 更新但**不带** rules:只把录取名次调小,一样会把库里已有的第 6 档截断,同样要拦。 + status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{ + "code": "individual_top6", "name": "个人项目前六名", "awardRanks": 5, + }, true) + if status != http.StatusBadRequest || resp.Error == nil || resp.Error.Code != "VALIDATION_FAILED" { + t.Fatalf("把 awardRanks 调小到 5 应 400 VALIDATION_FAILED,得到 %d %+v", status, resp.Error) + } + if !strings.Contains(resp.Error.Message, "第 6 档") { + t.Fatalf("文案应点明库里已有的超档档位,得到 %q", resp.Error.Message) + } + var stored int + if err := env.ev.QueryRow(`SELECT award_ranks FROM scoring_profiles WHERE id = 1`).Scan(&stored); err != nil { + t.Fatalf("读录取名次: %v", err) + } + if stored != 6 { + t.Fatalf("被拒的更新不该改录取名次,得到 %d", stored) + } + + // 对照:正好覆盖(第 6 档 + awardRanks=6)与不封顶(第 7 档 + awardRanks=0)都必须放行。 + if status, resp = env.do(client, http.MethodPut, "/api/scoring-profiles/1", map[string]any{ + "code": "individual_top6", "name": "个人项目前六名", "awardRanks": 6, "rules": sixRankRules(), + }, true); status != http.StatusOK || !resp.OK { + t.Fatalf("正好覆盖应 200,得到 %d %+v", status, resp.Error) + } + if status, resp = env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ + "code": "P-UNCAPPED", "name": "不封顶前七名", "awardRanks": 0, "rules": sevenRankRules(), + }, true); status != http.StatusOK || !resp.OK { + t.Fatalf("awardRanks=0 + 第 7 档应 200,得到 %d %+v", status, resp.Error) + } +} + +// 乙:awardRanks=0 表示不封顶——保存要成功,而且第 7 档必须**真能得分**。 +// 这条走完整链路(保存模板 → 把它挂到小项的计分通道 → 录 7 条成绩 → 发布 → 读流水), +// 只测校验函数的话,说不出「第 7 档到底算不算分」。 +func TestProfileAwardRanksZeroScoresSeventhPlace(t *testing.T) { + env, ids, client := recorderWithSeed(t) + + status, resp := env.do(client, http.MethodPost, "/api/scoring-profiles", map[string]any{ + "code": "P-UNCAPPED", "name": "不封顶前七名", "awardRanks": 0, "rules": sevenRankRules(), + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("awardRanks=0 + 第 7 档应保存成功,得到 %d %+v", status, resp.Error) + } + saved := decodeData[map[string]any](t, resp) + profileID := int64(saved["id"].(float64)) + if saved["awardRanks"] != float64(0) { + t.Fatalf("awardRanks=0 应原样落库,得到 %v", saved["awardRanks"]) + } + if ranks := profileRuleRanks(t, env, client, profileID); len(ranks) != 7 { + t.Fatalf("7 档分值表应全部保留,得到 %v", ranks) + } + + env.exec(t, `UPDATE event_score_channels SET profile_id = ? WHERE event_id = ?`, profileID, ids.event) + extra := addIndividualEntries(t, env, ids, 5) + // 成绩按录入顺序递增,7 人正好排出第 1~7 名(第 7 名成绩最差)。 + publishResults(t, env, client, ids.event, "award-uncapped", + map[string]any{"entryId": ids.entryA, "resultValue": 10, "resultText": "10", "unit": "秒", "status": "ok"}, + map[string]any{"entryId": ids.entryB, "resultValue": 11, "resultText": "11", "unit": "秒", "status": "ok"}, + map[string]any{"entryId": extra[0], "resultValue": 12, "resultText": "12", "unit": "秒", "status": "ok"}, + map[string]any{"entryId": extra[1], "resultValue": 13, "resultText": "13", "unit": "秒", "status": "ok"}, + map[string]any{"entryId": extra[2], "resultValue": 14, "resultText": "14", "unit": "秒", "status": "ok"}, + map[string]any{"entryId": extra[3], "resultValue": 15, "resultText": "15", "unit": "秒", "status": "ok"}, + map[string]any{"entryId": extra[4], "resultValue": 16, "resultText": "16", "unit": "秒", "status": "ok"}, + ) + + // 七个名次各拿一档分:第 7 名拿到 2 分就证明第 7 档没被截断。 + want := []struct { + entry int64 + points []float64 + }{ + {ids.entryA, []float64{10}}, + {ids.entryB, []float64{8}}, + {extra[0], []float64{6}}, + {extra[1], []float64{5}}, + {extra[2], []float64{4}}, + {extra[3], []float64{3}}, + {extra[4], []float64{2}}, + } + for i, tc := range want { + if pts := activeLedgerPoints(t, env, tc.entry); !equalFloats(pts, tc.points) { + t.Fatalf("第 %d 名的报名项 %d 应得 %v 分,得到 %v", i+1, tc.entry, tc.points, pts) + } + } +} + +// 规则包导入路径同一口径:YAML 里的 awardRanks 与 profiles[].rules 不一致时, +// 导入必须整体拒收(改前会安静导入,第 7 档在库里永远算 0 分)。 +func TestRulesImportRejectsAwardRanksOverflow(t *testing.T) { + env := newTestEnv(t) + client := adminClient(t, env) + + const pack = `format: scoreboard-rules +formatVersion: 1 +meta: { code: award-overflow, name: 第 7 档超出录取名次 } +standings: + individualToTeamWeight: 1 + tieBreak: [points, gold, silver, bronze, teamCode] +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 6 + tieMode: skip + rules: + - { rank: 1, points: 10 } + - { rank: 2, points: 8 } + - { rank: 3, points: 6 } + - { rank: 4, points: 5 } + - { rank: 5, points: 4 } + - { rank: 6, points: 3 } + - { rank: 7, points: 2 } +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: individual_top6 } +` + before := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r + JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`) + status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(pack), "application/yaml", true) + if status != http.StatusBadRequest { + t.Fatalf("导入第 7 档 + awardRanks=6 的规则包应 400,得到 %d %s", status, raw) + } + if !strings.Contains(string(raw), "第 7 档") { + t.Fatalf("导入失败的提示应点明超出的档位,得到 %s", raw) + } + after := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r + JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`) + if after != before { + t.Fatalf("被拒的导入不该动库里的分值表,得到 %d → %d 档", before, after) + } + + // 对照:同一份包把录取名次改成 0(不封顶)就能导入。 + status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", + []byte(strings.Replace(pack, "awardRanks: 6", "awardRanks: 0", 1)), "application/yaml", true) + if status != http.StatusOK { + t.Fatalf("awardRanks=0 的同一份规则包应导入成功,得到 %d %s", status, raw) + } + if got := countRows(t, env, `SELECT COUNT(*) FROM scoring_profile_rules r + JOIN scoring_profiles p ON p.id = r.profile_id WHERE p.code = 'individual_top6'`); got != 7 { + t.Fatalf("导入成功后应有 7 档,得到 %d", got) + } +} + +// 库里**已经存在**的越档模板(修好之前从模板页存下的那种)必须照常导得出来: +// GET /api/rules/export 与赛事项目包是现场备份与排障的入口,把它们一起 500 掉是更大的事故 +// (同类伤疤:TestUpdateProfileCodeRequiredKeepsRulesExportUsable)。 +// 所以这条检查只在写入侧生效:导出原样给出,再导回来才拒。 +func TestLegacyAwardRanksOverflowStillExports(t *testing.T) { + env := newTestEnv(t) + client := adminClient(t, env) + + // 绕开接口直接写库,模拟「修好之前存下的」第 7 档 + awardRanks 6(现在两条写入路径都已拦下)。 + env.exec(t, `INSERT INTO scoring_profile_rules (profile_id, rank, points) + SELECT id, 7, 2 FROM scoring_profiles WHERE code = 'individual_top6'`) + + exported := env.exportRulesYAML(t, client) + if !strings.Contains(exported, "rank: 7") { + t.Fatalf("越档模板应原样导出,实际\n%s", exported) + } + + // 同一份导出内容导回来:写入侧必须拒,且提示点明超档的那一档。 + status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(exported), "application/yaml", true) + if status != http.StatusBadRequest { + t.Fatalf("把导出的越档规则包导回来应 400,得到 %d %s", status, raw) + } + if !strings.Contains(string(raw), "第 7 档") { + t.Fatalf("提示应点明超出的档位,得到 %s", raw) + } +} diff --git a/server/internal/httpapi/repo.go b/server/internal/httpapi/repo.go index ca54c00..26f8442 100644 --- a/server/internal/httpapi/repo.go +++ b/server/internal/httpapi/repo.go @@ -10,6 +10,7 @@ import ( "strings" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" + "git.yeij.top/AskaEth/Scoreboard/server/internal/rules" ) // 本文件是赛事库(event.db)的读写层。store 包只负责连接与迁移, @@ -1290,6 +1291,29 @@ func validateAwardRanks(awardRanks int) error { return nil } +// validateProfileAwardCoverage 拦住「分值表档位超出录取名次」的静默截断。 +// +// 与上一条不同,这里不是字段取值范围的问题,而是两个字段**合起来**不成立: +// award_ranks > 0 时算分引擎把超出名次的档位一律当 0 分(domain/scoring.go 的 basePoints), +// 保存侧却从不校验两边的档位数,于是「分值表配到第 7 档、录取名次还留着 6」能安静落库—— +// 模板页上第 7 档明明写着 2 分,榜上却是 0 分,界面上看不出任何异常(审查 01 P1)。 +// 与其让人配一份算不出分的表,不如在保存时拒绝,并把两种改法直接写进错误里。 +// +// list 传「保存后实际生效的档位」:本次带了 rules 就是它,没带就是库里现有的(只改 awardRanks +// 一样能把已有档位截断)。文案与规则包导入侧共用 rules.AwardRanksOverflow,两条路给同一句话。 +func validateProfileAwardCoverage(awardRanks int, list []profileRuleDTO) error { + maxRank := 0 + for _, r := range list { + if r.Rank > maxRank { + maxRank = r.Rank + } + } + if msg := rules.AwardRanksOverflow(awardRanks, maxRank); msg != "" { + return badRequest(msg, nil) + } + return nil +} + func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, error) { in.Code, in.Name = strings.TrimSpace(in.Code), strings.TrimSpace(in.Name) if in.Code == "" || in.Name == "" { @@ -1306,6 +1330,9 @@ func (s *server) createProfile(ctx context.Context, in profileDTO) (profileDTO, if err := validateAwardRanks(in.AwardRanks); err != nil { return profileDTO{}, err } + if err := validateProfileAwardCoverage(in.AwardRanks, in.Rules); err != nil { + return profileDTO{}, err + } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return profileDTO{}, internal(err) @@ -1363,6 +1390,19 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr if err := validateAwardRanks(in.AwardRanks); err != nil { return profileDTO{}, err } + // 没带 rules 时按库里现有的档位校验:只把 awardRanks 调小,一样会把已有档位截断。 + // 这一步必须在开事务之前读(赛事库只开一条连接,事务里再查会自锁)。 + effectiveRules := in.Rules + if effectiveRules == nil { + cur, err := s.profileRules(ctx, id) + if err != nil { + return profileDTO{}, err + } + effectiveRules = cur + } + if err := validateProfileAwardCoverage(in.AwardRanks, effectiveRules); err != nil { + return profileDTO{}, err + } tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return profileDTO{}, internal(err) @@ -1400,15 +1440,15 @@ func (s *server) updateProfile(ctx context.Context, id int64, in profileDTO) (pr return s.getProfile(ctx, id) } -func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, rules []profileRuleDTO) error { +func replaceProfileRules(ctx context.Context, tx *sql.Tx, profileID int64, list []profileRuleDTO) error { if _, err := tx.ExecContext(ctx, `DELETE FROM scoring_profile_rules WHERE profile_id = ?`, profileID); err != nil { return internal(err) } // 名次去重:主键是 (profile_id, rank),模板页把两行填成同一个数字就会撞 UNIQUE, // 报出来是 500 + 英文约束原文。事务虽然会回滚,但错误无法自解释,现场只能去猜。 // 先自己查一遍,报清是哪一名重复(与上面 rank < 1 的 400 同一层)。 - seen := make(map[int]bool, len(rules)) - for _, r := range rules { + seen := make(map[int]bool, len(list)) + for _, r := range list { if r.Rank < 1 { return badRequest("积分名次必须 >= 1", r) } diff --git a/server/internal/httpapi/repo_events.go b/server/internal/httpapi/repo_events.go index a796d47..53d229f 100644 --- a/server/internal/httpapi/repo_events.go +++ b/server/internal/httpapi/repo_events.go @@ -269,6 +269,18 @@ func (s *server) bumpEvent(ctx context.Context, tx *sql.Tx, eventID int64) error return bumpVersion(ctx, tx, "events", eventID, "upsert", ev, middleware.User(ctx).ID) } +// deleteEvent 删除小项:作废它名下的成绩流水,再删通道 / 组别 / 小项本身。 +// +// 守卫只挡「已有报名数据」,所以「只有团体名次、没有报名项」的小项(球类那种形态, +// 名次直接按单位录进 channel_manual_ranks)走得通。这类小项删除时,它产生的流水必须 +// 在同一个事务里显式作废:score_ledger.event_id 是 ON DELETE SET NULL,先删小项就只把 +// 外键置空(voided 仍是 0),分数留在榜上、而此后任何按 event_id 的重算都打不到这些行, +// 变成「永久残留、只能改库」(审查 01 的 P0)。 +// +// 作废而不是删除:保留流水行作为审计痕迹(谁在什么时候因为什么作废)。范围与 +// writeLedgerTx 的作废范围同口径——只动引擎产出的成绩分与破纪录加分,人工调整 +// (source_type = 'adjustment')不在此列,它由 point_adjustments 管,删小项只把它的 +// event_id 置空。 func (s *server) deleteEvent(ctx context.Context, id int64) error { has, err := exists(ctx, s.ev, `SELECT 1 FROM entries WHERE event_id = ? LIMIT 1`, id) if err != nil { @@ -282,6 +294,16 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error { return internal(err) } defer func() { _ = tx.Rollback() }() + + actorID := middleware.User(ctx).ID + // 必须排在下面那条 DELETE FROM events 之前:event_id 一被置空,这条件就再也匹配不上。 + if _, err := tx.ExecContext(ctx, + `UPDATE score_ledger SET voided = 1, voided_by = ?, voided_at = datetime('now'), void_reason = ? + WHERE voided = 0 AND event_id = ? AND source_type IN ('result', 'record_bonus')`, + nullID(actorID), "小项删除,本小项积分流水一并作废", id); err != nil { + return internal(err) + } + for _, stmt := range []string{ `DELETE FROM event_score_channels WHERE event_id = ?`, `DELETE FROM event_categories WHERE event_id = ?`, @@ -291,7 +313,11 @@ func (s *server) deleteEvent(ctx context.Context, id int64) error { return internal(err) } } - if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, middleware.User(ctx).ID); err != nil { + if err := bumpVersion(ctx, tx, "events", id, "void", map[string]any{"id": id}, actorID); err != nil { + return err + } + // 流水作废会直接改榜单内容:不推 standings,大屏会一直停在旧分上(要等下一次发布才刷新)。 + if err := s.bumpStandings(ctx, tx, actorID); err != nil { return err } if err := tx.Commit(); err != nil { @@ -607,7 +633,7 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, return nil, err } rows, err := s.ev.QueryContext(ctx, - `SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team, + `SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team, count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID) if err != nil { @@ -619,14 +645,19 @@ func (s *server) listChannels(ctx context.Context, eventID int64) ([]channelDTO, for rows.Next() { var ( c channelDTO + aggregateMode string countToTeam int countToMedals int countToTeamMedals int ) - if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam, + if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam, &countToMedals, &countToTeamMedals); err != nil { return nil, internal(err) } + // 只下发非缺省口径(sum):avg 是缺省值,老数据的响应体因此一个字节都不变。 + if aggregateMode == string(domain.AggregateSum) { + c.AggregateMode = aggregateMode + } c.CountToTeam = boolPtr(countToTeam != 0) c.CountToMedals = boolPtr(countToMedals != 0) c.CountToTeamMedals = boolPtr(countToTeamMedals != 0) @@ -647,6 +678,15 @@ func channelFlag(v *bool) bool { return v == nil || *v } +// channelAggregateMode 读请求体里的聚合口径:缺省(空串)落成 avg, +// 保证不带该字段的旧客户端保存通道时口径不变(与库里的默认值一致)。 +func channelAggregateMode(c channelDTO) string { + if c.AggregateMode == string(domain.AggregateSum) { + return string(domain.AggregateSum) + } + return string(domain.AggregateAvg) +} + func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channelDTO) ([]channelDTO, error) { tx, err := s.ev.BeginTx(ctx, nil) if err != nil { @@ -672,14 +712,20 @@ func (s *server) replaceChannels(ctx context.Context, eventID int64, in []channe default: return nil, badRequest("通道 basis 非法", i) } + switch c.AggregateMode { + case "", string(domain.AggregateAvg), string(domain.AggregateSum): + default: + return nil, badRequest("通道 aggregateMode 只能是 avg/sum", i) + } if c.ProfileID == 0 { return nil, badRequest("通道 profileId 必填", i) } if _, err := tx.ExecContext(ctx, - `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team, - count_to_medals, count_to_team_medals) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, boolToInt(channelCountsToTeam(c)), + `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, aggregate_mode, + count_to_team, count_to_medals, count_to_team_medals) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + eventID, c.Scope, c.Basis, c.ProfileID, c.SortOrder, channelAggregateMode(c), + boolToInt(channelCountsToTeam(c)), boolToInt(channelFlag(c.CountToMedals)), boolToInt(channelFlag(c.CountToTeamMedals))); err != nil { if isUniqueViolation(err) { return nil, conflict("同一 scope+basis 只能配置一个通道") diff --git a/server/internal/httpapi/repo_misc.go b/server/internal/httpapi/repo_misc.go index 41d89a7..0c59513 100644 --- a/server/internal/httpapi/repo_misc.go +++ b/server/internal/httpapi/repo_misc.go @@ -15,6 +15,7 @@ import ( "git.yeij.top/AskaEth/Scoreboard/server/internal/dto" "git.yeij.top/AskaEth/Scoreboard/server/internal/export" "git.yeij.top/AskaEth/Scoreboard/server/internal/middleware" + "git.yeij.top/AskaEth/Scoreboard/server/internal/revisions" ) // ---------- settings ---------- @@ -476,6 +477,7 @@ func splitSettingList(raw string) []string { // standingsSnapshot 构造与 GET /api/standings 完全同构的整张榜单, // 供 change_log 的 standings replace 载荷使用(契约要求 data 是榜单数组)。 +// 总积分榜与单位奖牌榜都要算:前者的顺序是数组顺序,后者的名次由 medalRank 带走。 func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.StandingRow, error) { in, err := s.loadStandingsInput(ctx, q) if err != nil { @@ -485,7 +487,11 @@ func (s *server) standingsSnapshot(ctx context.Context, q queryer) ([]dto.Standi if err != nil { return nil, err } - return dto.StandingRows(domain.BuildTeamStandings(in), domainTeamsFromMap(teams)), nil + return dto.StandingRows( + domain.BuildTeamStandings(in), + domain.BuildMedalStandings(in), + domainTeamsFromMap(teams), + ), nil } // domainTeamsFromMap 把 teams 表的 DTO 还原成 domain.Team,供 dto.StandingRows 补齐展示字段。 @@ -668,9 +674,12 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown } // 名次不再只读 results.rank:那是个人名次(见 applyComputedRanks), // 团体名次与聚合名次只落在 channel_ranks,读旧列会让下钻的名次列整列变空。 + // 组别与导出口径一致(export.loadTeamDetailRows 的 COALESCE(c.name, ca.name, '')): + // 「按队内平均」产出的聚合流水 entry_id 为空,取不到报名项的组别,得用 ca 回推的那个。 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, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, '') + `SELECT l.id, l.source_type, l.event_id, ev.code, ev.name, COALESCE(cat.name, ca.name), + l.athlete_id, a.name, `+breakdownRankExpr+`, r.result_text, l.points, COALESCE(l.created_at, ''), + l.entry_id FROM score_ledger l LEFT JOIN events ev ON ev.id = l.event_id LEFT JOIN entries e ON e.id = l.entry_id @@ -694,17 +703,21 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown defer func() { _ = rows.Close() }() out := teamBreakdownDTO{TeamID: team.ID, TeamCode: team.Code, TeamName: team.Name, Items: []breakdownItemDTO{}} + // 每条流水背后那条成绩的版本身份;聚合流水 / 人工调整没有报名项,留零值(追不了就不追)。 + keys := make([]revisions.Key, 0, 16) for rows.Next() { var ( item breakdownItemDTO eventID, athleteID sql.NullInt64 + entryID 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 { + &categoryName, &athleteID, &athleteName, &rank, &resultText, &item.Points, &item.CreatedAt, + &entryID); err != nil { return teamBreakdownDTO{}, internal(err) } item.EventID = i64Ptr(eventID) @@ -718,10 +731,76 @@ func (s *server) teamBreakdown(ctx context.Context, teamID int64) (teamBreakdown if item.AthleteID != nil && *item.AthleteID != 0 { item.Points *= weight } + // 只有成绩流水本身带得上修订痕迹(见 revisions.KeyFor):破纪录加分 / 人工调整 / 聚合流水留零值。 + key := revisions.Key{} + if item.EventID != nil { + key = revisions.KeyFor(item.SourceType, *item.EventID, entryID.Int64) + } + keys = append(keys, key) out.Items = append(out.Items, item) out.TotalPoints += item.Points } - return out, rows.Err() + if err := rows.Err(); err != nil { + return teamBreakdownDTO{}, internal(err) + } + s.attachBreakdownRevisions(ctx, out.Items, keys) + return out, nil +} + +// attachBreakdownRevisions 给明细行补上修订痕迹(谁在什么时候因为什么改过这条成绩)。 +// +// 查不出来只记日志、不报错:修订痕迹是「解释这一分怎么来的」的补充信息,追溯失败不该让 +// 整张明细打不开 —— 现场要看的是分,不是角标。此时字段保持 nil,前端显示为空。 +// +// 操作人姓名只发给 recorder 及以上:这个接口是公开的(大屏也读它),而「谁点错的」 +// 属于裁判自己要看的账,不该跟着 JSON 一起下发给未登录的访客 —— 与 /api/revisions +// 只对 recorder 开放是同一个口径。时间与原因照发:那是给观众的解释,不是人名的账。 +func (s *server) attachBreakdownRevisions(ctx context.Context, items []breakdownItemDTO, keys []revisions.Key) { + if len(keys) == 0 { + return + } + trails, err := revisions.Load(ctx, s.ev, keys) + if err != nil { + s.logger.Warn("读取成绩修订痕迹失败,明细按「未修订」展示", "err", err) + return + } + showActor := middleware.User(ctx).Role.Allows(auth.RoleRecorder) + actorIDs := make([]int64, 0, 4) + byActor := make(map[int]int64) + for i, key := range keys { + trail, ok := trails[key] + if !ok { + continue + } + count := trail.Count + items[i].RevisionCount = &count + if count == 0 { + continue + } + at := trail.At + items[i].LastRevisionAt = &at + if trail.Reason != "" { + reason := trail.Reason + items[i].LastRevisionReason = &reason + } + if trail.ActorID != nil && showActor { + actorIDs = append(actorIDs, *trail.ActorID) + byActor[i] = *trail.ActorID + } + } + if len(actorIDs) == 0 || s.sys == nil { + return + } + names, err := s.userNames(ctx, actorIDs) + if err != nil { + s.logger.Warn("读取修订操作人姓名失败,只显示时间与原因", "err", err) + return + } + for i, id := range byActor { + if name := names[id]; name != "" { + items[i].LastRevisedBy = &name + } + } } // ---------- records ---------- diff --git a/server/internal/httpapi/repo_misc_test.go b/server/internal/httpapi/repo_misc_test.go index ea49878..2fa053c 100644 --- a/server/internal/httpapi/repo_misc_test.go +++ b/server/internal/httpapi/repo_misc_test.go @@ -162,6 +162,115 @@ func TestTeamBreakdownAppliesIndividualWeight(t *testing.T) { } } +// 单位积分明细的「组别」列不能整列变空:聚合通道(按队内平均)产出的流水 entry_id 与 result_id +// 都为空,从 entries 取不到组别,接口要按「单位在本小项只参加了一个组别」回推那一个 +// (与 export.loadTeamDetailRows 的组别回推口径一致)。 +func TestTeamBreakdownAggregateRowKeepsCategory(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + // 模拟聚合通道产出的流水:entry_id 与 result_id 都为空(见 isAggregateLedger)。 + env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points, count_to_team) + VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event) + + status, env2 := env.do(client, http.MethodGet, + fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error) + } + items := decodeData[map[string]any](t, env2)["items"].([]any) + if len(items) != 1 { + t.Fatalf("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items)) + } + item := items[0].(map[string]any) + if got := item["categoryName"]; got != "男子" { + t.Fatalf("聚合流水的组别 = %v,期望 男子(按单位唯一参加的组别回推)", got) + } + // 聚合分不对应单个运动员,运动员列为空是合理的(前端降级成单位名);但组别不能跟着空。 + if name, ok := item["athleteName"]; ok && name != nil { + t.Fatalf("聚合流水不该带运动员名,得到 %v", name) + } +} + +// 正常发布的个人流水(走报名项)必须带齐「谁 · 哪个项目 · 哪个组别」: +// 大屏明细的「运动员」列就靠这一行数据,任何一项为空都会让现场只看到项目与名次。 +func TestTeamBreakdownIndividualRowCarriesWhoAndCategory(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + }, + }, true) + if status, env2 := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, env2.Error) + } + + status, env2 := env.do(client, http.MethodGet, + fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error) + } + items := decodeData[map[string]any](t, env2)["items"].([]any) + if len(items) != 1 { + t.Fatalf("明细行数 = %d,期望 1", len(items)) + } + item := items[0].(map[string]any) + for field, want := range map[string]any{ + "athleteName": "张伟", + "categoryName": "男子", + "eventName": "100米跑", + "resultText": "11.58", + } { + if got := item[field]; got != want { + t.Fatalf("明细行的 %s = %v,期望 %v(大屏明细要能说清是谁在哪个项目拿了多少分)", field, got, want) + } + } +} + +// 单位在同一小项参加了多个组别时回推不出组别,明细宁可留空也不猜(与导出一致)。 +func TestTeamBreakdownAmbiguousAggregateCategoryStaysEmpty(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + otherCat := env.insert(t, `INSERT INTO event_categories (event_id, name, multiplier, sort_order) + VALUES (?, '女子', 1, 1)`, ids.event) + otherAthlete := env.insert(t, `INSERT INTO athletes (name, gender, team_id, active) + VALUES ('王芳', '女', ?, 1)`, ids.teamA) + env.exec(t, `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, ids.event, otherCat, ids.teamA, otherAthlete) + env.exec(t, `INSERT INTO score_ledger (team_id, athlete_id, event_id, entry_id, result_id, + source_type, source_id, points, count_to_team) + VALUES (?, NULL, ?, NULL, NULL, 'result', 99, 15, 1)`, ids.teamA, ids.event) + + status, env2 := env.do(client, http.MethodGet, + fmt.Sprintf("/api/standings/teams/%d/breakdown", ids.teamA), nil, false) + if status != http.StatusOK || !env2.OK { + t.Fatalf("读积分明细应 200,得到 %d %+v", status, env2.Error) + } + items := decodeData[map[string]any](t, env2)["items"].([]any) + if len(items) != 1 { + t.Fatalf("明细行数 = %d,期望 1(只插了一条聚合流水)", len(items)) + } + if got := items[0].(map[string]any)["categoryName"]; got != nil { + t.Fatalf("两个组别都参加时组别应留空(不猜),得到 %v", got) + } +} + // 只排名不计分(scored=0)的项目即使配了计分通道,也不得在大屏奖牌榜产出奖牌; // 导出侧已按 ev.scored = 1 过滤,两边口径必须一致,否则奖牌榜与成绩册对不上。 func TestStandingsExcludesUnscoredEventMedals(t *testing.T) { diff --git a/server/internal/httpapi/rules_handlers.go b/server/internal/httpapi/rules_handlers.go index 30aecb4..2a1a757 100644 --- a/server/internal/httpapi/rules_handlers.go +++ b/server/internal/httpapi/rules_handlers.go @@ -2,6 +2,9 @@ package httpapi import ( "context" + "database/sql" + "errors" + "fmt" "io" "net/http" @@ -129,17 +132,18 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error }, nil } - res, err := rules.Apply(s.ev, cs) + res, rows, err := s.applyRulesAndRecompute(r.Context(), cs, middlewareUserID(r)) if err != nil { - return nil, internal(err) - } - // 分值一变,历史成绩的积分与名次必须全量重算。 - rows, err := s.recomputeAllEvents(r.Context(), middlewareUserID(r)) - if err != nil { - // 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」; - // 必须显式告知调用方,不能报成功让操作员以为重算已完成。 - s.logger.Error("规则导入后重算失败", "err", err) - return nil, internalMsg("规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入", err) + // 配置本身就不成立(compare=none 的小项被配上 basis=result 等):落地前就拒了, + // 一个字节都没写,属于 400 而不是「服务端异常」。 + var conflict *rules.CompareConflictError + if errors.As(err, &conflict) { + return nil, badRequest(conflict.Error(), nil) + } + // 其余情况是重算在某个小项上失败,整包(规则 + 积分 + 名次)已跟着回滚, + // 榜单维持导入前的状态。原始错误带着「哪个小项、因为什么」,由 detail 一起回给现场。 + s.logger.Error("规则导入失败,已整体回滚", "err", err) + return nil, internalMsg("规则包未导入:积分重算失败,本次规则与积分改动已整体回滚,榜单维持导入前的状态(原因见后)", err) } s.audit(r.Context(), "rules.import", "rules", 0, nil, res) return rulesImportResult{ @@ -151,22 +155,80 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error }, nil } -// recomputeAllEvents 全量重算所有项目的积分流水与名次,用于规则包导入等会改变分值的场景。 -// 整个重算共用一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态。 -// 返回写入的积分流水条数,供导入结果回报。 +// applyRulesAndRecompute 把「规则包落地」与「全量重算」放进**同一个事务**,成功才提交。 +// +// 这两步必须同事务:换通道会先删 event_score_channels,而 channel_ranks.channel_id 挂的是 +// `ON DELETE CASCADE`,名次会跟着被清空、靠紧接着的重算重建。以前两步各自提交,重算一失败 +// (例如某个小项配不出名次)名次表就停在空表状态:榜单「分还在、奖牌全 0」,而且自愈与重试导入 +// 都会因同一个小项再次失败,无人可解(审查 04 P0-1,实测 2 行 → 0 行、金牌 1 → 0)。 +// 现在要么整体成功,要么整体回滚到导入前。 +// +// 返回落地统计与重算写回的流水条数(后者供导入结果回报)。 +func (s *server) applyRulesAndRecompute(ctx context.Context, cs *rules.ChangeSet, actorID int64) (*rules.ApplyResult, int, error) { + tx, err := s.ev.BeginTx(ctx, nil) + if err != nil { + return nil, 0, internal(err) + } + defer func() { _ = tx.Rollback() }() + + res, err := rules.ApplyTx(tx, cs) + if err != nil { + return nil, 0, err + } + rows, err := s.recomputeAllEventsTx(ctx, tx, actorID) + if err != nil { + return nil, 0, err + } + if err := tx.Commit(); err != nil { + return nil, 0, internal(err) + } + return res, rows, nil +} + +// recomputeAllEvents 全量重算所有项目的积分流水与名次(自带事务),用于改积分模板等 +// 会改变分值的场景。返回写入的积分流水条数,供调用方回报。 func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, error) { - rows, err := s.ev.QueryContext(ctx, `SELECT id FROM events ORDER BY id`) + tx, err := s.ev.BeginTx(ctx, nil) if err != nil { return 0, err } - var ids []int64 + defer func() { _ = tx.Rollback() }() + + total, err := s.recomputeAllEventsTx(ctx, tx, actorID) + if err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return total, nil +} + +// recomputeAllEventsTx 在调用方的事务里全量重算,自己不提交。 +// +// 整个重算共用调用方这一个事务:任一项目失败就全部回滚,不会留下「只重算了一部分」的中间状态; +// 规则包导入正是靠这一点把「换通道」与「重算」绑在一起(见 applyRulesAndRecompute)。 +// +// 每个小项的失败都带上小项编码与名称:只回一句「basis=result 的通道要求小项配置 compare=asc/desc」, +// 现场在一场几十上百个小项的赛事里定位不到是哪一个,也就无从修起。 +func (s *server) recomputeAllEventsTx(ctx context.Context, tx *sql.Tx, actorID int64) (int, error) { + rows, err := tx.QueryContext(ctx, `SELECT id, code, name FROM events ORDER BY id`) + if err != nil { + return 0, err + } + type target struct { + id int64 + code string + name string + } + var events []target for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { + var t target + if err := rows.Scan(&t.id, &t.code, &t.name); err != nil { _ = rows.Close() return 0, err } - ids = append(ids, id) + events = append(events, t) } if err := rows.Close(); err != nil { return 0, err @@ -175,21 +237,15 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er return 0, err } - tx, err := s.ev.BeginTx(ctx, nil) - if err != nil { - return 0, err - } - defer func() { _ = tx.Rollback() }() - total := 0 - for _, eventID := range ids { - score, err := recomputeLedgerTx(ctx, tx, eventID, 0, actorID, "rules-import") + for _, ev := range events { + score, err := recomputeLedgerTx(ctx, tx, ev.id, 0, actorID, "rules-import") if err != nil { - return 0, err + return 0, fmt.Errorf("小项 %s(%s)重算失败:%w", ev.code, ev.name, err) } // 名次随分值一起变化,必须回写 results,否则奖牌榜与积分榜互相矛盾。 if err := applyComputedRanks(ctx, tx, score.Ranked); err != nil { - return 0, err + return 0, fmt.Errorf("小项 %s(%s)回写名次失败:%w", ev.code, ev.name, err) } total += len(score.Ledger) } @@ -197,8 +253,5 @@ func (s *server) recomputeAllEvents(ctx context.Context, actorID int64) (int, er if err := s.bumpStandings(ctx, tx, actorID); err != nil { return 0, err } - if err := tx.Commit(); err != nil { - return 0, err - } return total, nil } diff --git a/server/internal/httpapi/rules_handlers_test.go b/server/internal/httpapi/rules_handlers_test.go index bc4a55f..b36db13 100644 --- a/server/internal/httpapi/rules_handlers_test.go +++ b/server/internal/httpapi/rules_handlers_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io" "net/http" + "reflect" "strings" "testing" ) @@ -183,8 +184,152 @@ func TestRulesImportReturnsContractFields(t *testing.T) { } } -// 重算失败必须显式报错,且整个重算在同一事务里回滚,不能留下「新规则 + 部分新积分」(P1-12)。 -func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) { +// rulesPackTemplateOnly 是一份「只按 eventType 匹配、不看 compare」的规则包:individual 小项 +// 一律被配上 basis=result。库里若存在 compare=none(名次靠人工录入)的个人小项,这份模板 +// 就会给它配上一条根本跑不起来的通道——这正是审查 04 P0-1 的触发条件。 +// 模板分值与本仓库默认模板一致,保证测试里的差异只出现在通道上。 +const rulesPackTemplateOnly = `format: scoreboard-rules +formatVersion: 1 +meta: { code: test-template-only, name: 只按 eventType 匹配的规则包 } +standings: + individualToTeamWeight: 1 + tieBreak: [points, gold, silver, bronze, teamCode] +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 6 + tieMode: skip + rules: + - { rank: 1, points: 10 } + - { rank: 2, points: 8 } + - { rank: 3, points: 6 } + - { rank: 4, points: 3 } + - { rank: 5, points: 2 } + - { rank: 6, points: 1 } + - code: team_top6 + name: 团体项目前六名 + awardRanks: 6 + tieMode: skip + rules: + - { rank: 1, points: 15 } + - { rank: 2, points: 10 } + - { rank: 3, points: 8 } + - { rank: 4, points: 4 } + - { rank: 5, points: 2 } + - { rank: 6, points: 1 } +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: individual_top6 } +` + +// rulesPackFixedBroken 是同一份规则包加上「坏小项改用 basis=manual」的 override: +// 导入失败提示就是这么让现场自己救回来的(compare=none 的小项只能用人工名次通道)。 +const rulesPackFixedBroken = rulesPackTemplateOnly + ` +eventOverrides: + - eventCode: Z_BROKEN + channels: + - { scope: athlete, basis: manual, profile: individual_top6 } +` + +// seedCompareNoneEvent 造一个 compare=none 的个人小项并发布一条人工名次。 +// 发布走真实接口,好让它占住一行 channel_ranks——那份名次就是「被级联清空就补不回来」的数据。 +func seedCompareNoneEvent(t *testing.T, env *testEnv, client *http.Client, ids seedIDs, code, name string) 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 (?, ?, ?, 'individual', 'manual', 'none', 'none', 1, 1, 'ongoing')`, ids.sport, code, name) + categoryID := env.insert(t, + `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, eventID) + 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', 'manual', ?, 0)`, eventID, profileID) + entryID := env.insert(t, + `INSERT INTO entries (event_id, category_id, team_id, athlete_id, status) + VALUES (?, ?, ?, ?, 'registered')`, eventID, categoryID, ids.teamA, ids.athleteA) + + status, resp := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": eventID, + "requestId": "draft-" + code, + "results": []map[string]any{{"entryId": entryID, "rank": 1, "status": "ok"}}, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("保存手工名次应 200,得到 %d %+v", status, resp.Error) + } + status, resp = env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": eventID, + "requestId": "publish-" + code, + }, true) + if status != http.StatusOK || !resp.OK { + t.Fatalf("发布手工名次应 200,得到 %d %+v", status, resp.Error) + } + return eventID +} + +// 审查 04 P0-1 的回归:库里存在 compare=none 的个人小项时导入只按 eventType 匹配的规则包。 +// +// 导入前 channel_ranks 有 3 行(M100 两条 + 手工小项一条)、A 队 1 金;旧代码在这里会把通道 +// 先换掉(channel_ranks 被外键级联清空)、随后重算在手工小项上报 ErrCompareRequired, +// 结果是 3 行 → 0 行、金牌 1 → 0,而自愈与「重试导入」都以同一个错误失败,无人可解。 +// 修完之后:要么整体成功,要么整体回滚到导入前;名次表**不允许**变成空的。 +func TestRulesImportWithCompareNoneEventRollsBackOrSucceeds(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("admin", "admin-pass", "admin") + client, _ := env.client() + env.login(client, "admin", "admin-pass") + env.publishTwoResults(t, client, ids) + seedCompareNoneEvent(t, env, client, ids, "Z_MANUAL", "手榴弹投准") + + beforeRanks := channelRankCount(t, env) + beforeMedals := medalsByTeam(t, env) + beforePoints := env.ledgerPointsByTeam(t) + if beforeRanks != 3 { + t.Fatalf("导入前应有 3 行通道名次(M100 两条 + 手工小项一条),得到 %d", beforeRanks) + } + + status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackTemplateOnly), "application/yaml", true) + + // 两种结局都算对:整体成功,或整体回滚回导入前。名次表变空就是失败。 + if status == http.StatusOK { + if got := channelRankCount(t, env); got == 0 { + t.Fatalf("导入返回成功却把通道名次清空了(%d → 0)", beforeRanks) + } + return + } + if status < http.StatusBadRequest { + t.Fatalf("导入既没成功也没失败,status=%d %s", status, raw) + } + // 报错要说人话:哪个小项、因为什么。 + for _, want := range []string{"Z_MANUAL", "手榴弹投准", "compare=none"} { + if !strings.Contains(string(raw), want) { + t.Errorf("导入失败提示缺少 %q:%s", want, raw) + } + } + // 名次表必须回到导入前的状态(不是空的)。 + if got := channelRankCount(t, env); got != beforeRanks { + t.Fatalf("导入失败应整体回滚,通道名次 %d 行 → %d 行", beforeRanks, got) + } + if got := medalsByTeam(t, env); !reflect.DeepEqual(got, beforeMedals) { + t.Fatalf("导入失败后奖牌榜应不变:before=%+v after=%+v", beforeMedals, got) + } + afterPoints := env.ledgerPointsByTeam(t) + if !reflect.DeepEqual(afterPoints, beforePoints) { + t.Fatalf("导入失败后流水应不变:before=%+v after=%+v", beforePoints, afterPoints) + } +} + +// 审查 04 P0-1 的第二个回归:重算失败时,「换通道」必须一起回滚。 +// +// 现场形态是旧代码留下的:库里已经有一个跑不起来的小项(compare=none 却配了 basis=result 通道), +// 规则包没有覆盖它(模板只匹配 individual,它是 match 类型),于是全量重算必然在它身上失败。 +// 旧代码此时通道替换已经提交,M100 的 channel_ranks 被级联清空 → 榜单「分还在、奖牌全 0」, +// 重试导入以同一个错误失败。修完后:整包回滚(名次表维持原样), +// 并且按提示给坏小项写一条 override 再导入就能修好——恢复得回来。 +func TestRulesImportRecomputeFailureRollsBackChannelReplacement(t *testing.T) { env := newTestEnv(t) ids := env.seed(t) env.createUser("admin", "admin-pass", "admin") @@ -192,17 +337,17 @@ func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) { env.login(client, "admin", "admin-pass") env.publishTwoResults(t, client, ids) + // 坏小项:compare=none(排不出名次)却配了 basis=result 的通道,还有一条已发布成绩。 + // 规则包的 individual 模板匹配不到它(event_type=match),所以它的通道不会被导入覆盖。 + broken := env.insert(t, + `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (?, 'Z_BROKEN', '损坏项目', 'match', 'auto', 'none', 'none', 1, 1, 'ongoing')`, ids.sport) + brokenCat := env.insert(t, + `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken) 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) } - // 第二个项目 compare=none 却配了 basis=result 通道,重算到这里必然报错。 - // 项目编码排在 M100 之后,保证替换到的第一个通道属于 M100。 - broken := env.insert(t, - `INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) - VALUES (?, 'Z_BROKEN', '损坏项目', 'individual', 'auto', 'time', 'none', 1, 1, 'ongoing')`, ids.sport) - brokenCat := env.insert(t, - `INSERT INTO event_categories (event_id, name, multiplier, sort_order) VALUES (?, '男子', 1, 0)`, broken) env.exec(t, `INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order) VALUES (?, 'athlete', 'result', ?, 0)`, broken, profileID) brokenEntry := env.insert(t, @@ -211,33 +356,53 @@ func TestRulesImportRecomputeFailureRollsBackAndReports(t *testing.T) { env.insert(t, `INSERT INTO results (event_id, entry_id, result_value, result_text, unit, status, state, rank) VALUES (?, ?, 11.0, '11.00', 's', 'ok', 'published', 1)`, broken, brokenEntry) - before := env.ledgerPointsByTeam(t) - pack := env.exportRulesYAML(t, client) - changed := strings.Replace(pack, "profile: individual_top6", "profile: team_top6", 1) - if changed == pack { - t.Fatalf("规则包里没有可替换的计分通道") + beforeRanks := channelRankCount(t, env) + beforeMedals := medalsByTeam(t, env) + beforePoints := env.ledgerPointsByTeam(t) + if beforeRanks != 2 { + t.Fatalf("导入前应有 2 行通道名次(M100 两条),得到 %d", beforeRanks) } - status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(changed), "application/yaml", true) + status, raw := env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackTemplateOnly), "application/yaml", true) if status != http.StatusInternalServerError { - t.Fatalf("重算失败应 500,得到 %d %s", status, raw) + t.Fatalf("坏小项让重算失败,应 500,得到 %d %s", status, raw) } - if !strings.Contains(string(raw), "积分重算失败") { - t.Fatalf("错误信息应说明重算失败,得到 %s", raw) + // 人话:必须指名是哪个小项、因为什么,现场才定位得到。 + for _, want := range []string{"Z_BROKEN", "损坏项目", "compare"} { + if !strings.Contains(string(raw), want) { + t.Errorf("重算失败提示缺少 %q:%s", want, raw) + } + } + // 换通道与重算同事务:失败即整体回滚,名次表回到导入前(不能变成空的)。 + if got := channelRankCount(t, env); got != beforeRanks { + t.Fatalf("重算失败应整体回滚,通道名次 %d 行 → %d 行", beforeRanks, got) + } + if got := medalsByTeam(t, env); !reflect.DeepEqual(got, beforeMedals) { + t.Fatalf("重算失败后奖牌榜应不变:before=%+v after=%+v", beforeMedals, got) } - - // 规则本身已经落库(Apply 先提交),但第一个项目的重算必须随第二个项目一起回滚。 var code string if err := env.ev.QueryRow(`SELECT p.code FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id WHERE c.event_id = ?`, ids.event).Scan(&code); err != nil { t.Fatalf("查询导入后的计分通道: %v", err) } - if code != "team_top6" { - t.Fatalf("规则应已落库(通道改为 team_top6),得到 %q", code) + if code != "individual_top6" { + t.Fatalf("通道替换也必须回滚,得到 %q", code) } - after := env.ledgerPointsByTeam(t) - if after[ids.teamA] != before[ids.teamA] || after[ids.teamB] != before[ids.teamB] { - t.Fatalf("重算失败应整体回滚,流水不应变化:before=%+v after=%+v", before, after) + afterPoints := env.ledgerPointsByTeam(t) + if !reflect.DeepEqual(afterPoints, beforePoints) { + t.Fatalf("重算失败后流水应不变:before=%+v after=%+v", beforePoints, afterPoints) + } + + // 可恢复:按提示给坏小项补一条 basis=manual 的 override,再导入就成功,且名次表被重建。 + status, raw = env.doRaw(client, http.MethodPost, "/api/rules/import", []byte(rulesPackFixedBroken), "application/yaml", true) + if status != http.StatusOK { + t.Fatalf("修正坏小项后导入应 200,得到 %d %s", status, raw) + } + if got := channelRankCount(t, env); got <= beforeRanks { + t.Fatalf("修正后名次表应被重建(含 Z_BROKEN 的一行),得到 %d 行", got) + } + if got := medalsByTeam(t, env); got[ids.teamA].Gold == 0 { + t.Fatalf("修正后奖牌榜应恢复,得到 %+v", got) } } diff --git a/server/internal/httpapi/scoring.go b/server/internal/httpapi/scoring.go index fa00857..c48fe32 100644 --- a/server/internal/httpapi/scoring.go +++ b/server/internal/httpapi/scoring.go @@ -86,7 +86,7 @@ func loadCategoriesQ(ctx context.Context, q queryer, eventID int64) ([]domain.Ev func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.ScoreChannel, error) { rows, err := q.QueryContext(ctx, - `SELECT id, event_id, scope, basis, profile_id, sort_order, count_to_team, + `SELECT id, event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team, count_to_medals, count_to_team_medals FROM event_score_channels WHERE event_id = ? ORDER BY sort_order, id`, eventID) if err != nil { @@ -98,14 +98,19 @@ func loadChannelsQ(ctx context.Context, q queryer, eventID int64) ([]domain.Scor for rows.Next() { var ( c domain.ScoreChannel + aggregateMode string countToTeam int countToMedals int countToTeamMedals int ) - if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &countToTeam, + if err := rows.Scan(&c.ID, &c.EventID, &c.Scope, &c.Basis, &c.ProfileID, &c.SortOrder, &aggregateMode, &countToTeam, &countToMedals, &countToTeamMedals); err != nil { return nil, internal(err) } + // 汇总口径只认 sum:avg(含缺字段的老库)保持零值,由引擎按缺省 avg 处理。 + if aggregateMode == string(domain.AggregateSum) { + c.AggregateMode = domain.AggregateSum + } // 只把显式关闭的通道落成指针,缺省时保持 nil(等同计入单位总分 / 发奖牌)。 if countToTeam == 0 { c.CountToTeam = boolPtr(false) @@ -545,14 +550,30 @@ func resultStatusPtr(s domain.ResultStatus) *string { // 团体通道(scope=team)与聚合 / 团体手工名次(ResultID=0)一律不碰这一列—— // 它们过去把个人名次覆盖成团体名次,双通道项目第二次发布会把同一份名次再发一次分(P0)。 // +// 这一列还是 manual 通道的**输入**(个人手工名次走它、团体报名项的团体名次也走它兜底), +// 所以命中「人工名次的录入位」时必须让路,否则同一份数据第二次发布会算出另一个结果: +// +// - 小项配了 [athlete, manual]:每一条成绩的 results.rank 都是操作员填的输入, +// 自动名次覆盖它 → 人工通道下一轮读到的是自动名次(工单 A-2:A 队 18 → 20 分); +// - 小项配了 [team, manual] 且该行是**团体报名项**(AthleteID == 0):它的 results.rank +// 是「单位名次」而不是个人名次(domain.collectTeamManualCandidates 的兜底来源), +// 写进自动的个人名次后会被当成团体名次再发一遍团体分(工单 A-1:10/8/6 → 25/18/14)。 +// // 同一 result 命中多个个人通道时取一条:basis=result 优先(这一列本来就是成绩名次), // 其次通道 id 小者,保证同一份输入每次写回同一个值。 func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) error { + inputs, err := manualRankInputEvents(ctx, tx, ranked) + if err != nil { + return err + } best := make(map[int64]domain.RankedResult) for _, rr := range ranked { if rr.Scope != domain.ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 { continue } + if in := inputs[rr.EventID]; in.athleteManual || (in.teamManual && rr.AthleteID == 0) { + continue + } cur, ok := best[rr.ResultID] if !ok || preferComputedRank(rr, cur) { best[rr.ResultID] = rr @@ -566,6 +587,39 @@ func applyComputedRanks(ctx context.Context, tx *sql.Tx, ranked []domain.RankedR return nil } +// manualRankInputs 描述一个小项把 results.rank 当作「人工名次录入位」的两种通道形态。 +type manualRankInputs struct { + athleteManual bool // [athlete, manual]:每条成绩的 results.rank 都是人工录入的个人名次 + teamManual bool // [team, manual]:团体报名项的 results.rank 是人工录入的团体名次 +} + +// manualRankInputEvents 查出这些名次所属小项里,哪些把 results.rank 当输入用。 +// 只查 ranked 里出现的小项:没有名次可写回时不需要这一趟查询, +// 而「有没有 manual 通道」必须看库(不能只看 ranked——名次还没录时该通道不产出明细)。 +func manualRankInputEvents(ctx context.Context, tx *sql.Tx, ranked []domain.RankedResult) (map[int64]manualRankInputs, error) { + if len(ranked) == 0 { + return nil, nil + } + out := make(map[int64]manualRankInputs) + for _, rr := range ranked { + if rr.EventID == 0 { + continue + } + if _, done := out[rr.EventID]; done { + continue + } + var athlete, team int + if err := tx.QueryRowContext(ctx, + `SELECT COALESCE(MAX(CASE WHEN scope = 'athlete' AND basis = 'manual' THEN 1 ELSE 0 END), 0), + COALESCE(MAX(CASE WHEN scope = 'team' AND basis = 'manual' THEN 1 ELSE 0 END), 0) + FROM event_score_channels WHERE event_id = ?`, rr.EventID).Scan(&athlete, &team); err != nil { + return nil, internal(err) + } + out[rr.EventID] = manualRankInputs{athleteManual: athlete != 0, teamManual: team != 0} + } + return out, nil +} + // preferComputedRank 判断候选 a 是否比已选中的 b 更该写回 results.rank。 func preferComputedRank(a, b domain.RankedResult) bool { if (a.Basis == domain.BasisResult) != (b.Basis == domain.BasisResult) { diff --git a/server/internal/httpapi/standings_medal_rank_test.go b/server/internal/httpapi/standings_medal_rank_test.go new file mode 100644 index 0000000..12e2acb --- /dev/null +++ b/server/internal/httpapi/standings_medal_rank_test.go @@ -0,0 +1,103 @@ +package httpapi_test + +import ( + "encoding/json" + "net/http" + "testing" +) + +// TestStandingsMedalRankIndependentOfPoints 是 06 报告 P1④ 的接口级回归用例 +// (主人 2026-09-13 拍板:单位奖牌榜独立按金→银→铜排序,与积分榜解耦)。 +// +// 造数:A 拿 1 金 10 分,B 拿 1 银 8 分,再给 B 记一笔 30 分的人工调整(不发奖牌)。 +// 期望:总积分榜仍按总分排(B 38 分第一),单位奖牌榜把 A 排第一(gold 1 > 0)。 +// 修前两个榜同序,奖牌榜会把金牌 1 枚的 A 排在金牌 0 枚的 B 后面,正是报告里的现象。 +func TestStandingsMedalRankIndependentOfPoints(t *testing.T) { + env := newTestEnv(t) + ids := env.seed(t) + env.createUser("recorder", "recorder-pass", "recorder") + client, _ := env.client() + env.login(client, "recorder", "recorder-pass") + + if status, body := env.do(client, http.MethodPut, "/api/results/draft", map[string]any{ + "eventId": ids.event, + "requestId": "draft-1", + "results": []map[string]any{ + {"entryId": ids.entryA, "resultValue": 11.58, "resultText": "11.58", "unit": "s", "status": "ok"}, + {"entryId": ids.entryB, "resultValue": 11.92, "resultText": "11.92", "unit": "s", "status": "ok"}, + }, + }, true); status != http.StatusOK { + t.Fatalf("保存草稿应 200,得到 %d %+v", status, body.Error) + } + if status, body := env.do(client, http.MethodPost, "/api/results/publish", map[string]any{ + "eventId": ids.event, + "requestId": "publish-1", + }, true); status != http.StatusOK { + t.Fatalf("发布应 200,得到 %d %+v", status, body.Error) + } + + // 人工调整只加分不发奖牌,正好用来造「金牌多、总分低」。 + env.exec(t, `UPDATE settings SET value = 'true' WHERE key = 'manual_adjustment.enabled'`) + if status, body := env.do(client, http.MethodPost, "/api/adjustments", map[string]any{ + "teamId": ids.teamB, "delta": 30, "category": "bonus", "reason": "接口用例造数", + }, true); status != http.StatusOK { + t.Fatalf("人工调整应 200,得到 %d %+v", status, body.Error) + } + + // REST:GET /api/standings 的 rank 与 medalRank 必须是两套口径。 + _, resp := env.do(client, http.MethodGet, "/api/standings", nil, false) + assertMedalRankDecoupled(t, "REST /api/standings", decodeData[[]map[string]any](t, resp)) + + // 大屏那条路:standings 变更的载荷(change_log 里固化的那份,WS 原样推送)同源。 + var payload string + if err := env.ev.QueryRow(`SELECT payload_json FROM change_log + WHERE entity_type = 'standings' ORDER BY id DESC LIMIT 1`).Scan(&payload); err != nil { + t.Fatalf("读取 standings 变更: %v", err) + } + var pushed []map[string]any + if err := json.Unmarshal([]byte(payload), &pushed); err != nil { + t.Fatalf("standings 载荷应为数组: %v (%s)", err, payload) + } + assertMedalRankDecoupled(t, "WS standings 载荷", pushed) +} + +// assertMedalRankDecoupled 断言榜单载荷里「积分榜按总分、奖牌榜按金牌」两套名次并存。 +func assertMedalRankDecoupled(t *testing.T, label string, rows []map[string]any) { + t.Helper() + // 字段缺失时给一句人话,不要用裸类型断言(旧服务端的载荷里没有 medalRank)。 + num := func(row map[string]any, key string) float64 { + value, ok := row[key].(float64) + if !ok { + t.Fatalf("%s:字段 %q 不是数字(缺失 / 类型不对):%v", label, key, row[key]) + } + return value + } + + byCode := make(map[string]map[string]any, len(rows)) + for _, row := range rows { + code, _ := row["teamCode"].(string) + byCode[code] = row + } + if len(byCode) != 2 { + t.Fatalf("%s:应含 2 个单位,得到 %d(%v)", label, len(byCode), rows) + } + a, b := byCode["A"], byCode["B"] + + // 总积分榜:B 靠 30 分人工调整反超,仍是 38 分第一。 + if got := num(b, "totalPoints"); got != 38 { + t.Fatalf("%s:B 总分 = %v,期望 38(8 + 调整 30)", label, got) + } + if num(b, "rank") != 1 || num(a, "rank") != 2 { + t.Fatalf("%s:总积分榜名次应 B 在 A 前(B 38 分),得到 A:%v B:%v", + label, a["rank"], b["rank"]) + } + + // 单位奖牌榜:A 有 1 金(B 无牌),名次必须独立算成第一。 + if num(a, "gold") != 1 || num(b, "gold") != 0 { + t.Fatalf("%s:奖牌数不对,A 应 1 金、B 应 0 金:%v %v", label, a, b) + } + if num(a, "medalRank") != 1 || num(b, "medalRank") != 2 { + t.Fatalf("%s:单位奖牌榜名次应 A 第一、B 第二(按金牌),得到 A:%v B:%v", + label, a["medalRank"], b["medalRank"]) + } +} diff --git a/server/internal/httpapi/system_test.go b/server/internal/httpapi/system_test.go index 3e4f4c7..3b57816 100644 --- a/server/internal/httpapi/system_test.go +++ b/server/internal/httpapi/system_test.go @@ -266,7 +266,7 @@ func TestSettingsAndStandingsJSONContract(t *testing.T) { } assertKeys(t, "standings[0]", rows[0], []string{ "teamId", "teamCode", "teamName", "shortName", "color", "logoPath", - "rank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange", + "rank", "medalRank", "gold", "silver", "bronze", "medalTotal", "totalPoints", "rankChange", }) } diff --git a/server/internal/realtime/snapshot.go b/server/internal/realtime/snapshot.go index 7a75f89..1b38bf9 100644 --- a/server/internal/realtime/snapshot.go +++ b/server/internal/realtime/snapshot.go @@ -232,15 +232,21 @@ func loadStandings(ctx context.Context, tx *sql.Tx, snap *Snapshot, teams []doma activeTeams = append(activeTeams, t) } } - rows := domain.BuildTeamStandings(domain.StandingsInput{ + in := domain.StandingsInput{ Teams: activeTeams, Ledger: ledger, Ranked: ranked, TieBreak: splitTieBreak(values["standings.tie_break"]), IndividualToTeamWeight: atofDefault(values["standings.individual_to_team_weight"], 1), ExcludeTeamEventMedals: !boolDefault(values["standings.medal_counts_team_events"], true), - }) - snap.Standings = dto.StandingRows(rows, teams) + } + // 单位奖牌榜的名次独立于总积分榜(金→银→铜→编码),随 medalRank 下发; + // 与 REST、xlsx 导出走的是同一个 domain.BuildMedalStandings。 + snap.Standings = dto.StandingRows( + domain.BuildTeamStandings(in), + domain.BuildMedalStandings(in), + teams, + ) return nil } diff --git a/server/internal/realtime/snapshot_test.go b/server/internal/realtime/snapshot_test.go index 37ac326..c1f92db 100644 --- a/server/internal/realtime/snapshot_test.go +++ b/server/internal/realtime/snapshot_test.go @@ -120,7 +120,8 @@ func TestSnapshotWireContract(t *testing.T) { if len(rows) != 1 { t.Fatalf("榜单行数应为 1,得到 %d", len(rows)) } - for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank"} { + // medalRank 是单位奖牌榜的独立名次,必须随快照下发,否则大屏奖牌页只能退回积分榜名次。 + for _, key := range []string{"teamId", "teamCode", "teamName", "totalPoints", "medalTotal", "rankChange", "gold", "rank", "medalRank"} { if _, ok := rows[0][key]; !ok { t.Fatalf("standings 缺少契约字段 %s: %s", key, top["standings"]) } diff --git a/server/internal/revisions/trail.go b/server/internal/revisions/trail.go new file mode 100644 index 0000000..4bda53e --- /dev/null +++ b/server/internal/revisions/trail.go @@ -0,0 +1,211 @@ +// Package revisions 从 result_revisions 的版本链里还原「这条成绩被改过几次、最后一次因为什么改」。 +// +// 两个消费方:单位积分明细的屏幕展示(httpapi.teamBreakdown)与 xlsx 导出(export.loadTeamDetailRows)。 +// 口径必须同源 —— 屏幕上写着「已修订 ×2」而导出件写「已修订 ×1」,就是同一份数据两处打架, +// 所以查询与判定都收在这里,调用方只负责排版。 +// +// 判定「被改过」的判据(保守优先,宁可不显示数字也不误报): +// - 身份用 (event_id, entry_id):results.entry_id 唯一,且「退回草稿 / 作废成绩」只改这一行的 +// 状态、不换报名项,重新发布也只会作废旧流水、写新流水。所以按这条线数历史版本, +// 不会被「重新发布产生新流水行」打断(ledger_ids 写入侧早已不写,result_id 也会随发布变化)。 +// - 只比「录进去的成绩」本身:成绩文本 / 数值 / 单位 / 并列 / 状态。 +// 刻意不比 rank 与 state:名次是引擎每次发布按当前成绩重算的(别人改了成绩,我的名次也会变), +// state 是发布动作自己写的,两者都会在「这条成绩一个字没改」的情况下逐次发布都不同。 +// - 至少要见过两次快照才可能报数:第一次出现是基线,不计;一次都没出现过(例如升级前就发布、 +// 没有版本记录)则整条不返回,调用方据此显示为空。 +package revisions + +import ( + "context" + "database/sql" + "strings" +) + +// Key 是一条成绩在版本链里的身份:同一个小项里的同一个报名项。 +type Key struct { + EventID int64 + EntryID int64 +} + +// KeyFor 返回一条流水在版本链里的身份;返回零值表示这条流水追不了修订痕迹(调用方按空显示)。 +// +// 只认**成绩流水本身**(source_type = 'result'):破纪录加分与人工调整是站在成绩旁边的另一笔分, +// 把成绩的修订次数挂到它们头上,等于对外说「那笔加分被改过」—— 那不是事实,正是要避免的误报。 +// 聚合流水(按队内平均)与团体手工名次没有单个报名项,同样返回零值。 +func KeyFor(sourceType string, eventID, entryID int64) Key { + if sourceType != "result" || eventID == 0 || entryID == 0 { + return Key{} + } + return Key{EventID: eventID, EntryID: entryID} +} + +// Trail 是一条成绩的修订痕迹。Count == 0 表示追溯成功但没有改动过(调用方不应显示角标)。 +type Trail struct { + Count int + // At 是最后一次改动落库的时间,SQLite datetime('now') 的原样字符串(UTC,不带时区)。 + At string + // Reason 是最后一次改动的原因;确实没记录过就是空串 —— 宁可不写也不编。 + Reason string + // ActorID 是最后一次改动的操作人(指向系统库 users,跨库不能建外键,解析姓名由调用方负责)。 + ActorID *int64 +} + +// Queryer 是 *sql.DB / *sql.Tx 的最小公共面。 +type Queryer interface { + QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) +} + +// snapshotColumns 从快照里的每条成绩对象上取出的字段。 +// +// 取的就是 httpapi 的 resultDTO 序列化后的 JSON 键(该类型不导出,改字段名要同步这里)。 +// 只取判定用得到的几项:rank / state / verifiedBy 之类故意不取,见包注释。 +const snapshotColumns = `json_extract(v.value, '$.entryId'), + COALESCE(json_extract(v.value, '$.resultText'), ''), + json_extract(v.value, '$.resultValue'), + COALESCE(json_extract(v.value, '$.unit'), ''), + COALESCE(json_extract(v.value, '$.isTie'), 0), + COALESCE(json_extract(v.value, '$.status'), ''), + COALESCE(json_extract(v.value, '$.note'), '')` + +// chunk 是一次 IN 查询里最多带几个 Key:事件与报名项各一个 IN 列表,参数个数翻倍, +// SQLite 的宿主参数上限(默认 32766)不该被一次明细请求摸到。 +const chunk = 100 + +// Load 汇总这些成绩的修订痕迹。返回的 map 里只包含「在版本链里出现过」的 Key。 +// +// 读不出来(快照损坏等)时返回错误,由调用方决定降级:明细是展示层的东西, +// 不该因为追溯不出来就整个接口失败。 +func Load(ctx context.Context, q Queryer, keys []Key) (map[Key]Trail, error) { + out := make(map[Key]Trail) + seen := make(map[Key]bool) + unique := make([]Key, 0, len(keys)) + for _, k := range keys { + if k.EventID == 0 || k.EntryID == 0 || seen[k] { + continue + } + seen[k] = true + unique = append(unique, k) + } + for start := 0; start < len(unique); start += chunk { + end := start + chunk + if end > len(unique) { + end = len(unique) + } + batch := unique[start:end] + eventIDs := make([]any, 0, len(batch)) + entryIDs := make([]any, 0, len(batch)) + wanted := make(map[Key]bool, len(batch)) + for _, k := range batch { + eventIDs = append(eventIDs, k.EventID) + entryIDs = append(entryIDs, k.EntryID) + wanted[k] = true + } + // json_each 把快照数组摊成一行行成绩,只把命中的那几行传回来 —— + // 事件攒了几十版快照、每版几百条成绩时,不值得整个 JSON 拉回来在 Go 里解析。 + query := `SELECT r.event_id, r.created_at, COALESCE(r.message, ''), r.actor_id, ` + snapshotColumns + ` + FROM result_revisions r, json_each(r.snapshot_json) v + WHERE r.event_id IN (` + placeholders(len(eventIDs)) + `) + AND json_extract(v.value, '$.entryId') IN (` + placeholders(len(entryIDs)) + `) + ORDER BY r.event_id, r.id` + args := append(append([]any{}, eventIDs...), entryIDs...) + if err := scan(ctx, q, query, args, wanted, out); err != nil { + return nil, err + } + } + return out, nil +} + +// snapshotValue 是一条成绩在某一版快照里的「录进去的成绩」。 +type snapshotValue struct { + text string + value sql.NullFloat64 + unit string + tie int + status string +} + +// changed 判断这一版是不是和上一版录的不是同一个成绩了。 +// +// 浮点按原值比较:正负零、NaN 这类边界在成绩录入里不会出现(服务端只接受有限数), +// 而 11.58 与 11.580000000000002 这种舍入差异本就不该被当成「改过」。 +func (v snapshotValue) changed(o snapshotValue) bool { + if v.text != o.text || v.unit != o.unit || v.tie != o.tie || v.status != o.status { + return true + } + if v.value.Valid != o.value.Valid { + return true + } + return v.value.Valid && v.value.Float64 != o.value.Float64 +} + +func scan(ctx context.Context, q Queryer, query string, args []any, wanted map[Key]bool, out map[Key]Trail) error { + rows, err := q.QueryContext(ctx, query, args...) + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + prev := make(map[Key]snapshotValue, len(wanted)) + for rows.Next() { + var ( + eventID, entryID int64 + createdAt, message string + actorID sql.NullInt64 + note string + value sql.NullFloat64 + text, unit, status string + tie int + ) + if err := rows.Scan(&eventID, &createdAt, &message, &actorID, &entryID, &text, &value, + &unit, &tie, &status, ¬e); err != nil { + return err + } + key := Key{EventID: eventID, EntryID: entryID} + if !wanted[key] { + continue + } + cur := snapshotValue{text: text, value: value, unit: unit, tie: tie, status: status} + // 第一次出现只是基线(「发布」本身不是修订),从第二次起才谈得上「改没改」。 + if before, ok := prev[key]; ok { + if before.changed(cur) { + trail := out[key] + trail.Count++ + trail.At = createdAt + trail.Reason = reasonOf(note, message) + trail.ActorID = nil + if actorID.Valid { + id := actorID.Int64 + trail.ActorID = &id + } + out[key] = trail + } + } else { + if _, ok := out[key]; !ok { + out[key] = Trail{} // 见过的标记:Count 0 = 追溯成功、没改过 + } + } + prev[key] = cur + } + return rows.Err() +} + +// reasonOf 取这次改动的原因,先具体后笼统: +// +// - results.note 是这条成绩自己的备注,「退回草稿 / 作废成绩」时写下的理由就落在它上面; +// - r.message 是修订行自带的消息,目前只有发布时「跳过 N 条只填了成绩文本的草稿」这一种, +// 是**发布级**说明,挂到某一条成绩头上未必对,所以只在前者没有时才用。 +// +// 两个都没有就返回空串:调用方只显示时间,不编原因。 +func reasonOf(note, message string) string { + if s := strings.TrimSpace(note); s != "" { + return s + } + return strings.TrimSpace(message) +} + +func placeholders(n int) string { + if n == 0 { + return "" + } + return strings.TrimSuffix(strings.Repeat("?,", n), ",") +} diff --git a/server/internal/revisions/trail_test.go b/server/internal/revisions/trail_test.go new file mode 100644 index 0000000..c3faa0d --- /dev/null +++ b/server/internal/revisions/trail_test.go @@ -0,0 +1,229 @@ +package revisions_test + +import ( + "context" + "database/sql" + "encoding/json" + "testing" + + _ "modernc.org/sqlite" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/revisions" +) + +// 一版快照里的成绩,字段名与 httpapi.resultDTO 的 JSON 对齐(只列判定用得到的)。 +type snapResult struct { + EntryID int64 `json:"entryId"` + Rank *int `json:"rank"` + ResultText string `json:"resultText"` + ResultValue *float64 `json:"resultValue"` + Unit string `json:"unit"` + IsTie bool `json:"isTie"` + Status string `json:"status"` + State string `json:"state"` + Note *string `json:"note"` +} + +type revision struct { + eventID int64 + message string + actorID any + created string + results []snapResult +} + +func newDB(t *testing.T, rows []revision) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", "file::memory:?cache=shared") + if err != nil { + t.Fatalf("打开内存库: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + if _, err := db.Exec(`CREATE TABLE result_revisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, event_id INTEGER NOT NULL, action TEXT NOT NULL, + snapshot_json TEXT NOT NULL, actor_id INTEGER, message TEXT, created_at TEXT NOT NULL)`); err != nil { + t.Fatalf("建表: %v", err) + } + for _, row := range rows { + raw, err := json.Marshal(row.results) + if err != nil { + t.Fatalf("序列化快照: %v", err) + } + if _, err := db.Exec(`INSERT INTO result_revisions (event_id, action, snapshot_json, actor_id, message, created_at) + VALUES (?, 'publish', ?, ?, ?, ?)`, row.eventID, string(raw), row.actorID, row.message, row.created); err != nil { + t.Fatalf("插入修订: %v", err) + } + } + return db +} + +func f64(v float64) *float64 { return &v } +func str(v string) *string { return &v } + +// result 造一条成绩快照;名次与 state 每次都不同,用来证明它们不参与判定。 +func result(entryID int64, rank int, text string, value float64, state string) snapResult { + return snapResult{ + EntryID: entryID, Rank: &rank, ResultText: text, ResultValue: f64(value), + Unit: "s", Status: "ok", State: state, + } +} + +func load(t *testing.T, db *sql.DB, keys ...revisions.Key) map[revisions.Key]revisions.Trail { + t.Helper() + trails, err := revisions.Load(context.Background(), db, keys) + if err != nil { + t.Fatalf("Load: %v", err) + } + return trails +} + +// 发布 → 改成绩 → 重新发布:能读出「改过 1 次」、最后一次的时间与原因。 +func TestLoadCountsRepublishAfterEdit(t *testing.T) { + db := newDB(t, []revision{ + {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{ + result(11, 1, "11.58", 11.58, "published"), + result(12, 2, "11.92", 11.92, "published"), + }}, + {eventID: 1, created: "2026-09-13 02:50:00", results: []snapResult{ + // 退回草稿:只有 state 变,成绩一个字没动。 + result(11, 1, "11.58", 11.58, "draft"), + result(12, 2, "11.92", 11.92, "published"), + }}, + {eventID: 1, message: "", actorID: 7, created: "2026-09-13 02:58:00", results: []snapResult{ + result(11, 1, "11.42", 11.42, "published"), + result(12, 3, "11.92", 11.92, "published"), + }}, + }) + + trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 12}) + got := trails[revisions.Key{EventID: 1, EntryID: 11}] + if got.Count != 1 { + t.Fatalf("11 号成绩改过 1 次,得到 %d", got.Count) + } + if got.At != "2026-09-13 02:58:00" { + t.Fatalf("最后一次修订时间 = %q", got.At) + } + // 12 号成绩一个字没改,只有名次被引擎重算(2 → 3):不能算修订。 + if other, ok := trails[revisions.Key{EventID: 1, EntryID: 12}]; !ok || other.Count != 0 { + t.Fatalf("没改过的成绩不该报修订,得到 %+v(存在=%v)", other, ok) + } +} + +// 原因优先取这条成绩自己的备注(「退回草稿」写下的理由),修订行的 message 只作兜底。 +func TestLoadPrefersResultNoteAsReason(t *testing.T) { + db := newDB(t, []revision{ + {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{ + result(11, 1, "11.58", 11.58, "published"), + }}, + {eventID: 1, message: "跳过 3 条只填了成绩文本的草稿", actorID: 7, created: "2026-09-13 02:58:00", + results: []snapResult{func() snapResult { + r := result(11, 1, "11.42", 11.42, "published") + r.Note = str("成绩录入笔误") + return r + }()}}, + }) + got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}] + if got.Reason != "成绩录入笔误" { + t.Fatalf("原因应取成绩备注,得到 %q", got.Reason) + } + if got.ActorID == nil || *got.ActorID != 7 { + t.Fatalf("操作人 = %v,期望 7", got.ActorID) + } +} + +// 没有备注时退回修订行的 message;两者都没有就是空串(调用方只显示时间,不编原因)。 +func TestLoadReasonFallsBackToMessageThenEmpty(t *testing.T) { + db := newDB(t, []revision{ + {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}}, + {eventID: 1, message: "现场复核后更正", created: "2026-09-13 02:58:00", + results: []snapResult{result(11, 1, "11.42", 11.42, "published")}}, + {eventID: 1, created: "2026-09-13 03:10:00", results: []snapResult{result(11, 1, "11.30", 11.30, "published")}}, + }) + got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}] + if got.Count != 2 { + t.Fatalf("改了两次应报 2,得到 %d", got.Count) + } + if got.Reason != "" { + t.Fatalf("最后一次没写原因就应留空,得到 %q", got.Reason) + } + if got.At != "2026-09-13 03:10:00" { + t.Fatalf("最后一次修订时间 = %q", got.At) + } +} + +// 一次都没在版本链里出现过(没有基线)时不返回该 Key —— 调用方据此显示为空,而不是 0。 +func TestLoadOmitsKeysWithoutBaseline(t *testing.T) { + db := newDB(t, []revision{ + {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}}, + }) + trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 99}, + revisions.Key{EventID: 2, EntryID: 11}) + if _, ok := trails[revisions.Key{EventID: 1, EntryID: 99}]; ok { + t.Fatalf("没出现在任何快照里的成绩不该有痕迹") + } + if _, ok := trails[revisions.Key{EventID: 2, EntryID: 11}]; ok { + t.Fatalf("别的项目下的同号报名项不该被认成同一条成绩") + } + if got := trails[revisions.Key{EventID: 1, EntryID: 11}]; got.Count != 0 { + t.Fatalf("只发布过一次的成绩应追溯得到、且没改过,得到 %+v", got) + } +} + +// 「作废 / 改判」改的是状态不是成绩文本:也算改过(否则现场看不出这格被改判过)。 +func TestLoadCountsStatusChange(t *testing.T) { + db := newDB(t, []revision{ + {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{result(11, 1, "11.58", 11.58, "published")}}, + {eventID: 1, created: "2026-09-13 03:00:00", results: []snapResult{func() snapResult { + r := result(11, 1, "11.58", 11.58, "published") + r.Status = "dq" + return r + }()}}, + }) + if got := load(t, db, revisions.Key{EventID: 1, EntryID: 11})[revisions.Key{EventID: 1, EntryID: 11}]; got.Count != 1 { + t.Fatalf("改判(状态变化)应算改过 1 次,得到 %+v", got) + } +} + +// 同一条成绩的小项下别的成绩反复改,不能把它的修订次数带上去。 +func TestLoadKeepsEventsApart(t *testing.T) { + db := newDB(t, []revision{ + {eventID: 1, created: "2026-09-13 02:40:00", results: []snapResult{ + result(11, 1, "11.58", 11.58, "published"), result(21, 1, "22.10", 22.10, "published"), + }}, + {eventID: 1, created: "2026-09-13 03:00:00", results: []snapResult{ + result(11, 1, "11.58", 11.58, "published"), result(21, 1, "21.80", 21.80, "published"), + }}, + {eventID: 2, created: "2026-09-13 03:10:00", results: []snapResult{result(11, 1, "11.42", 11.42, "published")}}, + }) + trails := load(t, db, revisions.Key{EventID: 1, EntryID: 11}, revisions.Key{EventID: 1, EntryID: 21}, + revisions.Key{EventID: 2, EntryID: 11}) + if got := trails[revisions.Key{EventID: 1, EntryID: 11}].Count; got != 0 { + t.Fatalf("同一小项里别人改了,自己不算改过,得到 %d", got) + } + if got := trails[revisions.Key{EventID: 1, EntryID: 21}].Count; got != 1 { + t.Fatalf("真正改了的那条应报 1,得到 %d", got) + } + if got := trails[revisions.Key{EventID: 2, EntryID: 11}].Count; got != 0 { + t.Fatalf("另一个小项里的同号报名项不该受牵连,得到 %d", got) + } +} + +// 破纪录加分 / 人工调整 / 聚合流水没有「一条成绩」可追:KeyFor 不给它们身份, +// 免得把成绩的修订说成「那笔加分被改过」。 +func TestKeyForOnlyAcceptsScoreLedger(t *testing.T) { + if got := revisions.KeyFor("record_bonus", 1, 11); got != (revisions.Key{}) { + t.Fatalf("破纪录加分不该挂成绩的修订痕迹,得到 %+v", got) + } + if got := revisions.KeyFor("adjustment", 1, 11); got != (revisions.Key{}) { + t.Fatalf("人工调整不该挂成绩的修订痕迹,得到 %+v", got) + } + if got := revisions.KeyFor("result", 0, 11); got != (revisions.Key{}) { + t.Fatalf("没有小项的流水追不了,得到 %+v", got) + } + if got := revisions.KeyFor("result", 1, 0); got != (revisions.Key{}) { + t.Fatalf("聚合流水没有报名项,追不了,得到 %+v", got) + } + if got := revisions.KeyFor("result", 1, 11); got != (revisions.Key{EventID: 1, EntryID: 11}) { + t.Fatalf("成绩流水应给出身份,得到 %+v", got) + } +} diff --git a/server/internal/rules/aggregate_mode_test.go b/server/internal/rules/aggregate_mode_test.go new file mode 100644 index 0000000..6716970 --- /dev/null +++ b/server/internal/rules/aggregate_mode_test.go @@ -0,0 +1,220 @@ +package rules + +import ( + "database/sql" + "errors" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 聚合口径(basis=aggregate 的 avg / sum)在规则包这条路上的口径: +// 解析认 avg/sum、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum), +// 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。 +// +// 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和, +// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径。 +const aggregateModePackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo-rules, name: 示例规则包 } +standings: { individualToTeamWeight: 1, tieBreak: [points, gold, silver, bronze, teamCode] } +profiles: + - code: team_top6 + name: 团体项目前六名 + awardRanks: 2 + tieMode: skip + rules: + - { rank: 1, points: 15 } + - { rank: 2, points: 10 } +recordBonus: { enabled: false, mode: max } +manualAdjustment: { enabled: false, reasonRequired: true } +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: team_top6 } +eventOverrides: + - eventCode: EVT_SUM + name: 团体引体向上(总和) + channels: + - { scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum } + - eventCode: EVT_AVG + name: 团体引体向上(平均,显式写缺省值) + channels: + - { scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg } +` + +// seedAggregateEvents 建两个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。 +func seedAggregateEvents(t *testing.T, db *sql.DB) { + t.Helper() + sportID := insertID(t, db, `INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`) + for _, ev := range []struct{ code, name string }{ + {"EVT_SUM", "团体引体向上(总和)"}, + {"EVT_AVG", "团体引体向上(平均,显式写缺省值)"}, + } { + if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier) + VALUES (?, ?, ?, 'individual_team', 'auto', 'count', 'desc', 1, 1)`, sportID, ev.code, ev.name); err != nil { + t.Fatalf("插入项目 %s: %v", ev.code, err) + } + } +} + +// TestParseAggregateMode 守解析:avg / sum 都认,缺省即 avg,拼错的取值必须报错。 +func TestParseAggregateMode(t *testing.T) { + pack, err := Parse([]byte(aggregateModePackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + overrides := map[string]Channel{} + for _, ov := range pack.EventOverrides { + overrides[ov.EventCode] = (*ov.Channels)[0] + } + if got := overrides["EVT_SUM"].AggregatesBy(); got != domain.AggregateSum { + t.Errorf("aggregateMode: sum 应解析成 sum,实际 %q", got) + } + // 显式写 avg 与不写等价(缺省值),解析结果都要是 avg。 + if got := overrides["EVT_AVG"].AggregatesBy(); got != domain.AggregateAvg { + t.Errorf("aggregateMode: avg 应解析成 avg,实际 %q", got) + } + // 普通通道(basis=result)不写该字段时同样是缺省 avg,不影响任何行为。 + tmpl := pack.ChannelTemplates[0].Channels[0] + if tmpl.AggregateMode != "" || tmpl.AggregatesBy() != domain.AggregateAvg { + t.Errorf("缺省通道的汇总口径应为空(等价 avg),实际 %q", tmpl.AggregateMode) + } + + // 拼错的取值必须报错:静默按 avg 处理会让现场以为切到了总和。 + bad := strings.Replace(aggregateModePackYAML, "aggregateMode: sum", "aggregateMode: biggest", 1) + if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) { + t.Fatalf("非法聚合口径应报校验错误,实际 %v", err) + } else if !strings.Contains(err.Error(), "aggregateMode") { + t.Errorf("报错应点明 aggregateMode 字段,实际 %v", err) + } +} + +// TestApplyAndExportAggregateMode 守落库与导出: +// sum 落库并导出回来;显式 avg 落库成 avg、导出时按缺省省略(导出 → 导入不产生多余差异)。 +func TestApplyAndExportAggregateMode(t *testing.T) { + db := openEventDB(t) + seedAggregateEvents(t, db) + + next, err := Parse([]byte(aggregateModePackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if _, err := Apply(db, &ChangeSet{Next: next}); err != nil { + t.Fatalf("Apply: %v", err) + } + + modeOf := func(code string) string { + t.Helper() + var mode string + if err := db.QueryRow(`SELECT c.aggregate_mode FROM event_score_channels c + JOIN events e ON e.id = c.event_id WHERE e.code = ? AND c.basis = 'aggregate'`, code).Scan(&mode); err != nil { + t.Fatalf("读取 %s 的聚合口径: %v", code, err) + } + return mode + } + if got := modeOf("EVT_SUM"); got != string(domain.AggregateSum) { + t.Errorf("EVT_SUM 落库 = %q,期望 sum", got) + } + // 缺省口径也落成具体值 avg,引擎侧不必再判空。 + if got := modeOf("EVT_AVG"); got != string(domain.AggregateAvg) { + t.Errorf("EVT_AVG 落库 = %q,期望 avg", got) + } + + exported, err := Export(db, Meta{Code: "demo", Name: "导出"}) + if err != nil { + t.Fatalf("Export: %v", err) + } + exportedMode := map[string]string{} + for _, ov := range exported.EventOverrides { + exportedMode[ov.EventCode] = (*ov.Channels)[0].AggregateMode + } + // sum 是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把总和口径丢成平均。 + if exportedMode["EVT_SUM"] != string(domain.AggregateSum) { + t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"]) + } + // avg 是缺省值,导出文件里不出现,往返比对不会凭空多出差异。 + if exportedMode["EVT_AVG"] != "" { + t.Errorf("EVT_AVG 导出 = %q,期望省略(avg 是缺省值)", exportedMode["EVT_AVG"]) + } + + // 导出的包重新导入:库里已有 sum / avg,差异必须为空。 + cur, err := Load(db) + if err != nil { + t.Fatalf("Load: %v", err) + } + reparsed, err := Parse(mustMarshal(t, exported)) + if err != nil { + t.Fatalf("重新 Parse 导出结果: %v", err) + } + cs, err := Diff(cur, reparsed) + if err != nil { + t.Fatalf("Diff: %v", err) + } + if !cs.Empty() { + t.Fatalf("导出再导入不应有差异: %+v", cs.Channels) + } +} + +// TestDiffIgnoresExplicitDefaultAggregateMode 守差异比对: +// 规则包显式写 avg 与库里缺省值等价,不能被当成「通道改动」而触发一次无意义的重算。 +func TestDiffIgnoresExplicitDefaultAggregateMode(t *testing.T) { + db := openEventDB(t) + seedAggregateEvents(t, db) + + next, err := Parse([]byte(aggregateModePackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if _, err := Apply(db, &ChangeSet{Next: next}); err != nil { + t.Fatalf("Apply: %v", err) + } + cur, err := Load(db) + if err != nil { + t.Fatalf("Load: %v", err) + } + + // 库里 EVT_AVG 是 avg(导出时按缺省省略),规则包再显式写一遍 avg:语义相同,不该有差异。 + if cs, err := Diff(cur, next); err != nil { + t.Fatalf("Diff: %v", err) + } else if !cs.Empty() { + t.Fatalf("显式 avg 与缺省 avg 等价,不应有差异: %+v", cs.Channels) + } + + // 对照:把 EVT_AVG 换成 sum 就必须报出一次通道改动(否则现场改了口径却不重算)。 + target := strings.Replace(aggregateModePackYAML, + "{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }", + "{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }", 1) + changed, err := Parse([]byte(target)) + if err != nil { + t.Fatalf("Parse 改口径后的规则包: %v", err) + } + cs, err := Diff(cur, changed) + if err != nil { + t.Fatalf("Diff: %v", err) + } + hit := false + for _, ch := range cs.Channels { + if ch.EventCode == "EVT_AVG" { + hit = true + } + } + if !hit { + t.Fatalf("把口径从 avg 改成 sum 必须报出通道改动,实际 %+v", cs.Channels) + } +} + +// insertID 执行一条 INSERT 并返回自增 id。 +func insertID(t *testing.T, db *sql.DB, query string, args ...any) int64 { + t.Helper() + res, err := db.Exec(query, args...) + if err != nil { + t.Fatalf("执行 SQL 失败: %v\n%s", err, query) + } + id, err := res.LastInsertId() + if err != nil { + t.Fatalf("获取 id: %v", err) + } + return id +} diff --git a/server/internal/rules/apply.go b/server/internal/rules/apply.go index 94a675f..c43ddfa 100644 --- a/server/internal/rules/apply.go +++ b/server/internal/rules/apply.go @@ -17,14 +17,44 @@ type ApplyResult struct { EventsSkipped []string `json:"eventsSkipped,omitempty"` } -// Apply 把差异预览落地。规则包语义是「全量覆盖」,因此按 Next 的目标状态同步, -// 而不是逐条套用 diff 列表——这样重复导入同一份规则包是幂等的。 +// Apply 把差异预览落地(自带事务)。 +// +// 规则包语义是「全量覆盖」,因此按 Next 的目标状态同步,而不是逐条套用 diff 列表—— +// 这样重复导入同一份规则包是幂等的。 // 只填了 Next 的变更集(例如确认导入时重新 Parse 上传内容)同样可以调用,等价于强制全量导入。 // +// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。 +// 需要「换通道」与「重算」同事务时用 ApplyTx(规则包导入走的是那条路,见其注释)。 +func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { + tx, err := db.Begin() + if err != nil { + return nil, fmt.Errorf("开启事务: %w", err) + } + defer func() { _ = tx.Rollback() }() + + res, err := ApplyTx(tx, cs) + if err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("提交导入事务: %w", err) + } + return res, nil +} + +// ApplyTx 在调用方给定的**未提交**事务里落地规则包变更,自己不提交。 +// +// 为什么要有这个入口:换通道会先 `DELETE FROM event_score_channels` 再插新行,而 channel_ranks +// 的 channel_id 挂的是 `ON DELETE CASCADE`——通道一删,该通道的名次就跟着没了。名次由紧随其后的 +// 全量重算重建,两者分属不同事务时,只要重算失败(例如某个小项配不出名次),名次表就停在 +// 「已经被清空、又补不回来」的中间态:榜单分还在、奖牌全 0,而且自愈与重试导入都会因同一个小项 +// 再次失败(审查 04 P0-1,实测 2 行 → 0 行、金牌 1 → 0)。 +// 共用一个事务后,重算失败即整包回滚,名次表回到导入前的状态。 +// // 落地顺序:模板 → 通道 → 删除无主模板 → 项目开关 → 赛事设置。 // 通道先改完再删模板,避免「模板被换掉」时误判引用。 -// Apply 不负责重算积分:分值一变,调用方必须全量重算(devdocs 02 §7.3)。 -func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { +// 现状快照在**本事务内**读取(loadSnapshot(tx)),保证看到的就是即将写入的这份数据。 +func ApplyTx(tx *sql.Tx, cs *ChangeSet) (*ApplyResult, error) { if cs == nil || cs.Next == nil { return nil, ErrNilChangeSet } @@ -33,7 +63,7 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { return nil, err } - cur, err := Load(db) + cur, err := loadSnapshot(tx) if err != nil { return nil, err } @@ -42,12 +72,6 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { return nil, err } - tx, err := db.Begin() - if err != nil { - return nil, fmt.Errorf("开启事务: %w", err) - } - defer func() { _ = tx.Rollback() }() - res := &ApplyResult{EventsSkipped: unknown} // 1. 模板全量 upsert,并建立「编码 → id」映射供通道引用。 @@ -85,8 +109,9 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { if !ok { return nil, fmt.Errorf("项目 %s 的通道引用了不存在的模板 %q", code, ch.Profile) } - if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, count_to_team, count_to_medals, count_to_team_medals) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i, + if _, err := tx.Exec(`INSERT INTO event_score_channels (event_id, scope, basis, profile_id, sort_order, aggregate_mode, count_to_team, count_to_medals, count_to_team_medals) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, eventID, string(ch.Scope), string(ch.Basis), profileID, i, + string(ch.AggregatesBy()), boolToInt(ch.CountsToTeam()), boolToInt(ch.CountsToMedals()), boolToInt(ch.CountsToTeamMedals())); err != nil { return nil, fmt.Errorf("写入项目 %s 的计分通道: %w", code, err) } @@ -149,10 +174,6 @@ func Apply(db *sql.DB, cs *ChangeSet) (*ApplyResult, error) { } res.SettingsWritten++ } - - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("提交导入事务: %w", err) - } return res, nil } diff --git a/server/internal/rules/award_ranks_test.go b/server/internal/rules/award_ranks_test.go new file mode 100644 index 0000000..a2aaa97 --- /dev/null +++ b/server/internal/rules/award_ranks_test.go @@ -0,0 +1,139 @@ +package rules + +import ( + "errors" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" +) + +// 本文件钉住工单 E:分值表的档位超出「录取档位数(awardRanks)」时必须拒收。 +// +// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints), +// 而本包原先只拒 awardRanks < 0:一份「配到第 7 档、录取名次还留着 6」的规则包能过校验、 +// 安静导入——第 7 档在模板页上写着分,榜上恒为 0,界面上看不出任何异常(审查 01 P1)。 +// awardRanks: 0 表示不按名次封顶(既有语义),这种包必须继续放行。 + +// sevenRankPackYAML 是「配到第 7 档」的规则包;录取名次由用例替换成 6 / 0。 +const sevenRankPackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: { code: demo, name: 第七档 } +profiles: + - code: p7 + name: 前七名 + awardRanks: 6 + tieMode: skip + rules: + - { rank: 1, points: 10 } + - { rank: 2, points: 8 } + - { rank: 3, points: 6 } + - { rank: 4, points: 5 } + - { rank: 5, points: 4 } + - { rank: 6, points: 3 } + - { rank: 7, points: 2 } +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: p7 } +` + +// 录取名次 6、分值表到第 7 档:导入必须整体拒收,且说明要说清「哪一档超了」「怎么改」。 +func TestParseRejectsAwardRanksOverflow(t *testing.T) { + _, err := Parse([]byte(sevenRankPackYAML)) + if !errors.Is(err, ErrValidation) { + t.Fatalf("第 7 档 + awardRanks=6 应报 ErrValidation,实际 %v", err) + } + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("应返回 *ValidationError,实际 %T", err) + } + var found bool + for _, is := range ve.Issues { + if is.Path != "profiles[0].awardRanks" { + continue + } + found = true + // 现场要能照着改:说清超出的档位、当前录取名次,以及「0 表示不封顶」这个出口。 + for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} { + if !strings.Contains(is.Msg, want) { + t.Fatalf("说明里缺少 %q:%s", want, is.Msg) + } + } + } + if !found { + t.Fatalf("问题应挂在 profiles[0].awardRanks 上,实际 %+v", ve.Issues) + } +} + +// 这条检查只在**写入**侧生效:导出(Marshal)走的是 Validate,必须继续容忍越档模板。 +// 库里修好之前存下的历史配置如果让导不出来,GET /api/rules/export 与赛事项目包(备份)会一起 500, +// 而它们正是现场排障与备份的入口——所以导出侧宁可原样导出,由用户在模板页上改。 +func TestMarshalStillAllowsLegacyAwardRanksOverflow(t *testing.T) { + pack := &RulePack{ + Format: FormatName, + FormatVersion: FormatVersion, + Meta: Meta{Code: "legacy", Name: "历史越档模板"}, + Profiles: []Profile{{ + Code: "p7", Name: "前七名", AwardRanks: 6, TieMode: domain.TieSkip, + Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}}, + }}, + } + if err := pack.Validate(); err != nil { + t.Fatalf("导出侧校验不该拦越档模板,实际 %v", err) + } + if _, err := Marshal(pack); err != nil { + t.Fatalf("越档模板必须还能导出,实际 %v", err) + } + // 但同一份配置从导入进来时就要被拒(Parse → ValidateForImport)。 + if _, err := Parse([]byte(sevenRankPackYAML)); !errors.Is(err, ErrValidation) { + t.Fatalf("导入侧应拒收越档模板,实际 %v", err) + } +} + +// 两种合法形态不得被误杀:正好相等(第 6 档 + awardRanks 6)、不封顶(第 7 档 + awardRanks 0)。 +func TestParseAcceptsAwardRanksCoveringRules(t *testing.T) { + cases := map[string]string{ + "正好相等": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 7", 1), + "不封顶": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 0", 1), + } + for name, yaml := range cases { + t.Run(name, func(t *testing.T) { + pack, err := Parse([]byte(yaml)) + if err != nil { + t.Fatalf("应解析成功,实际 %v", err) + } + if got := len(pack.Profiles[0].Rules); got != 7 { + t.Fatalf("分值表应保留 7 档,实际 %d", got) + } + }) + } +} + +// AwardRanksOverflow 是保存模板与导入规则包共用的判定,边界直接钉在函数上。 +func TestAwardRanksOverflowBoundaries(t *testing.T) { + cases := []struct { + awardRanks int + maxRuleRank int + overflow bool + }{ + {0, 10, false}, // 0 = 不封顶 + {-1, 10, false}, // 负数由别处拦,这里不重复报 + {6, 0, false}, // 没有分值明细 + {6, 6, false}, // 正好覆盖 + {7, 6, false}, // 录取名次多于档位:合法(后面几档没人拿得到) + {6, 7, true}, + {6, 10, true}, + } + for _, tc := range cases { + msg := AwardRanksOverflow(tc.awardRanks, tc.maxRuleRank) + if tc.overflow != (msg != "") { + t.Fatalf("awardRanks=%d maxRuleRank=%d 应 overflow=%v,实际 %q", + tc.awardRanks, tc.maxRuleRank, tc.overflow, msg) + } + } + // 超出的档位取的是「最大的那一档」,不是第一条超出的档位。 + if msg := AwardRanksOverflow(6, 10); !strings.Contains(msg, "第 10 档") { + t.Fatalf("应点名最大的超档档位,实际 %q", msg) + } +} diff --git a/server/internal/rules/convert.go b/server/internal/rules/convert.go index 4b56f15..17fef24 100644 --- a/server/internal/rules/convert.go +++ b/server/internal/rules/convert.go @@ -32,14 +32,16 @@ func (p Profile) ToDomain(id int64) domain.ScoringProfile { } // ToDomain 把通道转成 domain.ScoreChannel;profileID 由调用方按模板编码查出。 -// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致。 +// 三个开关缺省(true)时不落指针,保持与「库里只有显式关闭的通道才写 0」一致; +// 汇总口径落库时补成具体值(avg / sum),缺省即 avg。 func (c Channel) ToDomain(eventID, profileID int64, sortOrder int) domain.ScoreChannel { out := domain.ScoreChannel{ - EventID: eventID, - Scope: c.Scope, - Basis: c.Basis, - ProfileID: profileID, - SortOrder: sortOrder, + EventID: eventID, + Scope: c.Scope, + Basis: c.Basis, + ProfileID: profileID, + SortOrder: sortOrder, + AggregateMode: c.AggregatesBy(), } if !c.CountsToTeam() { out.CountToTeam = BoolPtr(false) diff --git a/server/internal/rules/diff.go b/server/internal/rules/diff.go index 143f70c..502fd3f 100644 --- a/server/internal/rules/diff.go +++ b/server/internal/rules/diff.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "sort" + "strings" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // ChangeKind 是差异类型。 @@ -181,14 +184,18 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri } if ov, ok := overrideByCode[ev.Code]; ok && ov.Channels != nil { targets[ev.Code] = append([]Channel(nil), (*ov.Channels)...) - continue - } - for _, t := range next.ChannelTemplates { - if matchEvent(t.Match, ev) { - targets[ev.Code] = append([]Channel(nil), t.Channels...) - break + } else { + for _, t := range next.ChannelTemplates { + if matchEvent(t.Match, ev) { + targets[ev.Code] = append([]Channel(nil), t.Channels...) + break + } } } + // 目标通道算出来了,落地前先确认它在这个小项上跑得起来(见 checkCompareCompatible)。 + if err := checkCompareCompatible(ev, targets[ev.Code]); err != nil { + return nil, nil, err + } } var unknown []string @@ -201,6 +208,57 @@ func resolveTargets(cur *Snapshot, next *RulePack) (map[string][]Channel, []stri return targets, unknown, nil } +// CompareConflictError 是「规则包给 compare=none 的小项配了需要比较方向的通道」这一配置错误。 +// +// 带小项编码与名称是必须的:导入失败的提示如果只说「basis=result 的通道要求小项配置 +// compare=asc/desc」,现场在一场几十上百个小项的赛事里根本找不到是哪一个。 +type CompareConflictError struct { + EventCode string + EventName string + // Bases 是冲突的通道口径(result / aggregate),按通道出现顺序、已去重。 + Bases []string +} + +func (e *CompareConflictError) Error() string { + return fmt.Sprintf("小项 %s(%s)配置的是 compare=none(名次靠人工录入),规则包却给它配了 basis=%s 的通道:"+ + "这类通道要先按成绩排出名次,compare=none 时排不出来,导入后这个小项算不了分。"+ + "请在规则包里为它写一段 eventOverrides,把 channels 改成 basis=manual"+ + "(个人名次用 scope: athlete,团体名次用 scope: team),或调整通道模板的 match 条件别匹配到它。", + e.EventCode, e.EventName, strings.Join(e.Bases, "、")) +} + +// checkCompareCompatible 拦下「compare=none 的小项被配上 basis=result / basis=aggregate 通道」。 +// +// 为什么要在这里拦:这两种口径都要先按成绩排名,而 compare=none(名次靠人工录入)没有名次可算, +// domain.ScoreEvent 会直接返回 ErrCompareRequired。以前这条配置能过,代价是导入「先换通道」已经提交 +// (换通道时 channel_ranks 被外键级联清空)、紧接着的全量重算才报错,名次表就停在空表状态, +// 而且自愈与重试导入都会因同一个小项再次失败(审查 04 P0-1)。 +// 现在改成在**落地之前**整包拒收:一个字节都不写,失败提示直接指出哪个小项、为什么。 +// +// 只校验本次要写入的目标通道(模板套用与显式 override 一视同仁):库里既有的、规则包没覆盖到的 +// 通道不动它——那属于「改配置」而不是「导入规则包」的事,越权清理会误伤别人手工配好的设置。 +func checkCompareCompatible(ev EventInfo, channels []Channel) error { + if len(channels) == 0 || ev.Compare != domain.CompareNone { + return nil + } + var bases []string + seen := map[string]bool{} + for _, ch := range channels { + if ch.Basis != domain.BasisResult && ch.Basis != domain.BasisAggregate { + continue + } + if seen[string(ch.Basis)] { + continue + } + seen[string(ch.Basis)] = true + bases = append(bases, string(ch.Basis)) + } + if len(bases) == 0 { + return nil + } + return &CompareConflictError{EventCode: ev.Code, EventName: ev.Name, Bases: bases} +} + func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange { curByCode := make(map[string]EventInfo, len(cur.Events)) for _, ev := range cur.Events { @@ -240,7 +298,8 @@ func diffChannels(cur *Snapshot, targets map[string][]Channel) []ChannelChange { } // channelsEqual 逐字段比较目标通道与库中通道。 -// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比。 +// Channel 含 *bool 字段,整体比较会退化成比较指针地址,这里按补齐缺省后的值比; +// 汇总口径同理按「缺省即 avg」比,避免规则包显式写 avg 与库里缺省值被当成一次改动。 func channelsEqual(a, b []Channel) bool { if len(a) != len(b) { return false @@ -249,6 +308,9 @@ func channelsEqual(a, b []Channel) bool { if a[i].Scope != b[i].Scope || a[i].Basis != b[i].Basis || a[i].Profile != b[i].Profile { return false } + if a[i].AggregatesBy() != b[i].AggregatesBy() { + return false + } if a[i].CountsToTeam() != b[i].CountsToTeam() { return false } diff --git a/server/internal/rules/diff_test.go b/server/internal/rules/diff_test.go index 4bd342f..2c292cb 100644 --- a/server/internal/rules/diff_test.go +++ b/server/internal/rules/diff_test.go @@ -1,6 +1,8 @@ package rules import ( + "errors" + "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" @@ -258,6 +260,95 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) { }) } +// compare=none 的个人小项(名次靠人工录入)不能被配上 basis=result / basis=aggregate 的通道: +// 这两种口径都要先按成绩排名,而 compare=none 没有名次可算,算分时必然返回 ErrCompareRequired。 +// 以前这条配置能过,代价是「先换通道」(channel_ranks 被外键级联清空)已经提交、紧接着的重算才失败, +// 名次表停在空表状态且补不回来(审查 04 P0-1)。现在必须在落地之前整包拒收。 +func TestResolveTargetsRejectsCompareRequiredChannelOnCompareNoneEvent(t *testing.T) { + cur := currentSnapshot(t) + cur.Events = append(cur.Events, EventInfo{ + ID: 9, Code: "EVT_MANUAL", Name: "手榴弹投准", + EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone, + Compare: domain.CompareNone, Scored: true, Multiplier: 1, + Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}}, + }) + + next, err := Parse([]byte(nextPackYAML)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + t.Run("模板套出来的 basis=result 被拦下", func(t *testing.T) { + _, _, err := resolveTargets(cur, next) + var conflict *CompareConflictError + if !errors.As(err, &conflict) { + t.Fatalf("compare=none 的小项被配上 basis=result 应报 CompareConflictError,得到 %v", err) + } + if conflict.EventCode != "EVT_MANUAL" || conflict.EventName != "手榴弹投准" { + t.Errorf("错误里必须指名是哪个小项,得到 %+v", conflict) + } + // 人话:说清哪个小项、为什么、怎么改。 + msg := conflict.Error() + for _, want := range []string{"EVT_MANUAL", "手榴弹投准", "compare=none", "basis=result", "eventOverrides", "basis=manual"} { + if !strings.Contains(msg, want) { + t.Errorf("错误信息缺少 %q:%s", want, msg) + } + } + + // 预览阶段就必须失败:不能预览说「就改这些」,落地时才炸。 + if _, err := Diff(cur, next); !errors.As(err, &conflict) { + t.Errorf("Diff 也应报 CompareConflictError,得到 %v", err) + } + }) + + t.Run("显式 override 写 basis=result 一样被拦下", func(t *testing.T) { + patched := *next + patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{ + EventCode: "EVT_MANUAL", + Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}), + }) + var conflict *CompareConflictError + if _, _, err := resolveTargets(cur, &patched); !errors.As(err, &conflict) { + t.Errorf("override 显式写了 basis=result 也应拦下,得到 %v", err) + } + }) + + t.Run("override 改成 basis=manual 即放行", func(t *testing.T) { + patched := *next + patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{ + EventCode: "EVT_MANUAL", + Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}}), + }) + targets, _, err := resolveTargets(cur, &patched) + if err != nil { + t.Fatalf("basis=manual 不该被拦:%v", err) + } + if got := targets["EVT_MANUAL"]; len(got) != 1 || got[0].Basis != domain.BasisManual { + t.Errorf("EVT_MANUAL 的目标通道 = %+v,期望一条 basis=manual", got) + } + // 规则包没覆盖到的其它小项不受影响。 + if got := targets["EVT_IND"]; len(got) != 1 || got[0].Basis != domain.BasisResult { + t.Errorf("EVT_IND 的目标通道 = %+v,期望模板给的 basis=result", got) + } + }) + + t.Run("不计分的小项不参与校验", func(t *testing.T) { + unscored := currentSnapshot(t) + unscored.Events = append(unscored.Events, EventInfo{ + ID: 10, Code: "EVT_OFF", Name: "不计分手工名次", + EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone, + Compare: domain.CompareNone, Scored: false, Multiplier: 1, + }) + targets, _, err := resolveTargets(unscored, next) + if err != nil { + t.Fatalf("只排名不计分的小项不该被拦:%v", err) + } + if got, ok := targets["EVT_OFF"]; !ok || len(got) != 0 { + t.Errorf("不计分小项的目标通道 = %+v,期望空", got) + } + }) +} + // 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。 // 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。 // 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。 diff --git a/server/internal/rules/doc.go b/server/internal/rules/doc.go index 74e1e11..290ae4a 100644 --- a/server/internal/rules/doc.go +++ b/server/internal/rules/doc.go @@ -15,4 +15,6 @@ // yaml, err := rules.Marshal(out) // // 导入必须先预览再落地,且落地后必须由调用方全量重算积分(规则包一变,历史积分都得跟着变)。 +// 调用方若能把「落地」与「重算」放进同一个事务(规则包导入就是这么做的),用 ApplyTx 代替 Apply: +// 换通道会按外键级联清空 channel_ranks,两步分开提交时重算一失败名次表就补不回来了。 package rules diff --git a/server/internal/rules/last_place_zero_test.go b/server/internal/rules/last_place_zero_test.go new file mode 100644 index 0000000..5c37709 --- /dev/null +++ b/server/internal/rules/last_place_zero_test.go @@ -0,0 +1,153 @@ +package rules + +import ( + "strings" + "testing" +) + +// 随包规则包(rules/2026-驭电利刃杯军事运动会.yaml)里两个模板的「末位归零」都是关的: +// 主人 2026-09-13 的最终口径是「规则里面没配置的名次分数就是 0 分」——分只按表发, +// 系统不该替他多发或扣分。这个开关做的是另一件事:参赛人数不超过表里档位数时, +// 把名次最末的那一位也抹成 0(3 人参赛 + 6 档模板 → 第 3 名本该按表拿 6 分,却被归零), +// 那件事主人没要。一旦这里变成 true,重新导入规则包就会把这个开关静默打开,末位又丢分。 +// +// 为什么不直接读 rules/ 目录那个文件:规则包不进版本库(.gitignore 的 rules/*), +// 换台机器跑测试就读不到。所以这里放一份与随包文件同构的节选样本(只有本用例用得上的字段), +// 测的是「解析链路不会丢掉这个开关」——Parse → Canonical → ToDomain 任一环丢字段都会被抓到。 +const militaryGamesPackYAML = `format: scoreboard-rules +formatVersion: 1 +meta: + code: 2026-yudianliren-military-games + name: 2026 驭电利刃杯军事运动会 · 积分规则 + version: 1.1.0 + updatedAt: "2026-09-13" + author: AskaEth + note: 个人/团体项目均录取前六名 +standings: + individualToTeamWeight: 1 + tieBreak: [points, gold, silver, bronze, teamCode] + medalCountsTeamEvents: true + showIndividualBoard: true + showIndividualMedals: true +profiles: + - code: individual_top6 + name: 个人项目前六名 + awardRanks: 6 + lastPlaceZero: false + tieMode: skip + rules: + - { rank: 1, points: 10 } + - { rank: 2, points: 8 } + - { rank: 3, points: 6 } + - { rank: 4, points: 3 } + - { rank: 5, points: 2 } + - { rank: 6, points: 1 } + - code: team_top6 + name: 团体项目前六名 + awardRanks: 6 + lastPlaceZero: false + tieMode: skip + rules: + - { rank: 1, points: 15 } + - { rank: 2, points: 10 } + - { rank: 3, points: 8 } + - { rank: 4, points: 4 } + - { rank: 5, points: 2 } + - { rank: 6, points: 1 } +recordBonus: + enabled: false + mode: max + levels: + - { code: meet, name: 赛会纪录, points: 2 } +manualAdjustment: + enabled: false + reasonRequired: true +channelTemplates: + - match: { eventType: individual } + channels: + - { scope: athlete, basis: result, profile: individual_top6 } + - match: { eventType: team } + channels: + - { scope: team, basis: manual, profile: team_top6 } + - match: { eventType: match } + channels: + - { scope: team, basis: manual, profile: team_top6 } + - match: { eventType: individual_team } + channels: + - { scope: athlete, basis: result, profile: individual_top6, countToTeam: false } + - { scope: team, basis: aggregate, profile: team_top6 } +eventOverrides: + - eventCode: TEAM_PULLUP + name: 团体引体向上 + channels: + - { scope: athlete, basis: result, profile: individual_top6, countToTeam: false } + - { scope: team, basis: aggregate, profile: team_top6 } + - eventCode: FLIGHT_FITNESS + name: 飞行人员战斗体能 + scored: false +` + +func TestParseLastPlaceZeroDisabledForBothProfiles(t *testing.T) { + if n := strings.Count(militaryGamesPackYAML, "lastPlaceZero: false"); n != 2 { + t.Fatalf("内嵌样本应给两个模板各写一处 lastPlaceZero: false,实际 %d 处(样本与随包文件不同构了)", n) + } + + pack, err := Parse([]byte(militaryGamesPackYAML)) + if err != nil { + t.Fatalf("解析随包规则包样本失败: %v", err) + } + + index := pack.ProfileIndex() + for _, code := range []string{"individual_top6", "team_top6"} { + prof, ok := index[code] + if !ok { + t.Fatalf("随包规则包里缺少模板 %s", code) + } + if prof.LastPlaceZero { + t.Errorf("模板 %s 解析后 lastPlaceZero=true,应随主人的最终口径为 false(只按表发分)", code) + } + // 导入会把模板转成 domain.ScoringProfile 落库,转换丢字段同样等于「静默打开」。 + if dom := prof.ToDomain(0); dom.LastPlaceZero { + t.Errorf("模板 %s 转成 domain.ScoringProfile 后 lastPlaceZero=true,落库时会被打开", code) + } + } +} + +// 上面那条只有在「开关真会被读出来」时才有意义,所以补反向对照: +// 同一份样本把值改成 true / 整行删掉,解析结果必须跟着变—— +// 证明拿到 false 是因为 YAML 里写着 false(或没写),而不是解析器恒给 false。 +func TestParseLastPlaceZeroFollowsYAML(t *testing.T) { + cases := []struct { + name string + yaml string + want bool + }{ + { + name: "显式 true", + yaml: strings.ReplaceAll(militaryGamesPackYAML, "lastPlaceZero: false", "lastPlaceZero: true"), + want: true, + }, + { + name: "整行删掉(未配置,与显式 false 等价)", + yaml: strings.ReplaceAll(militaryGamesPackYAML, " lastPlaceZero: false\n", ""), + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.yaml == militaryGamesPackYAML { + t.Fatal("样本没有被改动,用例跑不出结论") + } + pack, err := Parse([]byte(tc.yaml)) + if err != nil { + t.Fatalf("解析样本失败: %v", err) + } + for _, prof := range pack.Profiles { + if prof.LastPlaceZero != tc.want { + t.Errorf("模板 %s 的 lastPlaceZero=%v,期望 %v", prof.Code, prof.LastPlaceZero, tc.want) + } + } + }) + } +} diff --git a/server/internal/rules/parse.go b/server/internal/rules/parse.go index 59c4fda..6addff0 100644 --- a/server/internal/rules/parse.go +++ b/server/internal/rules/parse.go @@ -44,6 +44,12 @@ func Parse(data []byte) (*RulePack, error) { if err := canonical.Validate(); err != nil { return nil, err } + // 导入是写入路径:在解析阶段就拦下「这份配置算不出分」的情况(例如分值表配到第 7 档、 + // awardRanks 还是 6,第 7 档进来就会静默变 0 分)。导出侧不走这里——历史库里已经存在的 + // 越档模板照样要能导出来(见 Validate 与 ValidateForImport 的注释)。 + if err := canonical.ValidateForImport(); err != nil { + return nil, err + } return &canonical, nil } diff --git a/server/internal/rules/snapshot.go b/server/internal/rules/snapshot.go index 2789563..ba48114 100644 --- a/server/internal/rules/snapshot.go +++ b/server/internal/rules/snapshot.go @@ -19,7 +19,18 @@ type EventInfo struct { Scored bool Multiplier float64 AwardMedals bool - Channels []Channel + // Compare 是排序方向。必须读出来:basis=result / basis=aggregate 的通道要先按成绩排名, + // compare=none(名次靠人工录入)的小项配不了它们(见 resolveTargets 的守卫)。 + Compare domain.CompareMode + Channels []Channel +} + +// queryer 是 *sql.DB 与 *sql.Tx 共有的读接口。 +// Load 既要在库上读,也要在导入事务**内部**读:ApplyTx 必须看到与自己写入同一个事务的一致视图, +// 否则「读到的现状」可能已被别处的改动覆盖,按过期现状算出的目标通道会写错。 +type queryer interface { + Query(query string, args ...any) (*sql.Rows, error) + QueryRow(query string, args ...any) *sql.Row } // Snapshot 是赛事库当前的规则状态:规则包 + 项目目录。 @@ -40,19 +51,22 @@ func (s *Snapshot) EventByCode(code string) (EventInfo, bool) { } // Load 从赛事库读取当前规则状态。规则包元数据(meta)库里不存,保持为空。 -func Load(db *sql.DB) (*Snapshot, error) { +func Load(db *sql.DB) (*Snapshot, error) { return loadSnapshot(db) } + +// loadSnapshot 是 Load 的实现体,接受 *sql.DB 或 *sql.Tx。 +func loadSnapshot(q queryer) (*Snapshot, error) { pack := &RulePack{ Format: FormatName, FormatVersion: FormatVersion, } - profileByID, profiles, err := loadProfiles(db) + profileByID, profiles, err := loadProfiles(q) if err != nil { return nil, err } pack.Profiles = profiles - settings, err := loadSettings(db) + settings, err := loadSettings(q) if err != nil { return nil, err } @@ -60,7 +74,7 @@ func Load(db *sql.DB) (*Snapshot, error) { pack.RecordBonus = recordBonusFromSettings(settings) pack.ManualAdjustment = manualAdjustmentFromSettings(settings) - events, err := loadEvents(db, profileByID) + events, err := loadEvents(q, profileByID) if err != nil { return nil, err } @@ -114,8 +128,8 @@ func Export(db *sql.DB, meta Meta) (*RulePack, error) { return &canonical, nil } -func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) { - rows, err := db.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero +func loadProfiles(q queryer) (map[int64]string, []Profile, error) { + rows, err := q.Query(`SELECT id, code, name, tie_mode, record_bonus_mode, award_ranks, last_place_zero FROM scoring_profiles ORDER BY code`) if err != nil { return nil, nil, fmt.Errorf("查询积分模板: %w", err) @@ -150,7 +164,7 @@ func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) { return nil, nil, fmt.Errorf("读取积分模板: %w", err) } - ruleRows, err := db.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`) + ruleRows, err := q.Query(`SELECT profile_id, rank, points FROM scoring_profile_rules ORDER BY profile_id, rank`) if err != nil { return nil, nil, fmt.Errorf("查询积分模板明细: %w", err) } @@ -178,8 +192,8 @@ func loadProfiles(db *sql.DB) (map[int64]string, []Profile, error) { return byID, profiles, nil } -func loadSettings(db *sql.DB) (map[string]string, error) { - rows, err := db.Query(`SELECT key, value FROM settings`) +func loadSettings(q queryer) (map[string]string, error) { + rows, err := q.Query(`SELECT key, value FROM settings`) if err != nil { return nil, fmt.Errorf("查询赛事设置: %w", err) } @@ -207,8 +221,8 @@ func settingByKey(db *sql.DB, key string) (string, error) { return value, nil } -func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { - rows, err := db.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.scored, e.multiplier, e.award_medals +func loadEvents(q queryer, profileByID map[int64]string) ([]EventInfo, error) { + rows, err := q.Query(`SELECT e.id, e.code, e.name, s.code, e.event_type, e.result_type, e.compare, e.scored, e.multiplier, e.award_medals FROM events e JOIN sports s ON s.id = e.sport_id ORDER BY e.sort_order, e.code`) if err != nil { @@ -225,11 +239,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { sportCode string eventType string resultType string + compare string scoredRaw int multiplier float64 awardMedalsRaw int ) - if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil { + if err := rows.Scan(&id, &code, &name, &sportCode, &eventType, &resultType, &compare, &scoredRaw, &multiplier, &awardMedalsRaw); err != nil { return nil, fmt.Errorf("读取项目: %w", err) } index[id] = len(events) @@ -240,6 +255,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { SportCode: sportCode, EventType: domain.EventType(eventType), ResultType: domain.ResultType(resultType), + Compare: domain.CompareMode(compare), Scored: scoredRaw != 0, Multiplier: multiplier, AwardMedals: awardMedalsRaw != 0, @@ -249,7 +265,7 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { return nil, fmt.Errorf("读取项目: %w", err) } - chRows, err := db.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.count_to_team, c.count_to_medals, c.count_to_team_medals + chRows, err := q.Query(`SELECT c.event_id, c.scope, c.basis, p.code, c.sort_order, c.aggregate_mode, c.count_to_team, c.count_to_medals, c.count_to_team_medals FROM event_score_channels c JOIN scoring_profiles p ON p.id = c.profile_id ORDER BY c.event_id, c.sort_order, c.id`) if err != nil { @@ -264,11 +280,12 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { basis string code string sortOrd int + aggregateMode string countToTeam int countToMedals int countToTeamMedals int ) - if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &countToTeam, &countToMedals, &countToTeamMedals); err != nil { + if err := chRows.Scan(&eventID, &scope, &basis, &code, &sortOrd, &aggregateMode, &countToTeam, &countToMedals, &countToTeamMedals); err != nil { return nil, fmt.Errorf("读取计分通道: %w", err) } idx, ok := index[eventID] @@ -280,6 +297,10 @@ func loadEvents(db *sql.DB, profileByID map[int64]string) ([]EventInfo, error) { Basis: domain.Basis(basis), Profile: code, } + // 只把非缺省的口径写进导出文件:avg 是缺省值,写了会让「导出 → 导入」凭空多出差异。 + if aggregateMode == string(domain.AggregateSum) { + ch.AggregateMode = string(domain.AggregateSum) + } // 只把显式关闭的通道写成 false:导出文件里不出现缺省值,往返比对才不会凭空多出差异。 if countToTeam == 0 { ch.CountToTeam = BoolPtr(false) diff --git a/server/internal/rules/types.go b/server/internal/rules/types.go index 508fad7..cd50e17 100644 --- a/server/internal/rules/types.go +++ b/server/internal/rules/types.go @@ -96,11 +96,15 @@ type Rule struct { } // Channel 是计分通道:排谁的名次 + 名次从哪来 + 用哪张积分表。 -// basis=aggregate 按同一主体成绩的平均值汇总,分母由项目的 avg_mode 决定。 +// basis=aggregate 把同一主体的成绩汇总成一个值再排名:汇总口径由 AggregateMode 决定 +// (avg 平均 / sum 求和),平均值内部的分母口径由项目的 avg_mode 决定。 type Channel struct { Scope domain.Scope `yaml:"scope" json:"scope"` Basis domain.Basis `yaml:"basis" json:"basis"` Profile string `yaml:"profile" json:"profile"` + // AggregateMode 是 basis=aggregate 通道的汇总口径:avg 按成员平均(缺省)/ sum 按成员总和。 + // 只对 aggregate 通道有意义;缺省(不写)等价于 avg,与库里的默认值一致。 + AggregateMode string `yaml:"aggregateMode,omitempty" json:"aggregateMode,omitempty"` // CountToTeam 决定该通道产出的流水是否计入单位总分;缺省 true。 // 「个人+团体」项目的个人通道写 false:分只记在个人头上,不进单位总分。 CountToTeam *bool `yaml:"countToTeam,omitempty" json:"countToTeam,omitempty"` @@ -112,6 +116,14 @@ type Channel struct { CountToTeamMedals *bool `yaml:"countToTeamMedals,omitempty" json:"countToTeamMedals,omitempty"` } +// AggregatesBy 返回补齐缺省值后的汇总口径:未配置或写 avg 时按 avg(与库里的默认值一致)。 +func (c Channel) AggregatesBy() domain.AggregateMode { + if c.AggregateMode == string(domain.AggregateSum) { + return domain.AggregateSum + } + return domain.AggregateAvg +} + // CountsToTeam 返回补齐缺省值后的开关(缺省 true)。 func (c Channel) CountsToTeam() bool { return boolOrDefault(c.CountToTeam, true) } diff --git a/server/internal/rules/validate.go b/server/internal/rules/validate.go index aa649ee..c0c9215 100644 --- a/server/internal/rules/validate.go +++ b/server/internal/rules/validate.go @@ -51,6 +51,13 @@ func (l *issueList) addf(path, format string, args ...any) { } // Validate 校验规则包。格式 / 版本不符立即返回哨兵错误,其余问题一次性汇总。 +// +// 它服务的**不只是**导入:导出(Marshal → Validate)走的是同一条路,而导出正是现场备份与排障的 +// 入口(GET /api/rules/export、赛事项目包里的 rules.yaml)。因此这里只放「这份文件是不是一份 +// 能读懂的规则包」,不放「这份配置能不能算分」——库里修好之前存下的历史配置(awardRanks 与 +// 分值表对不上的模板)如果让导不出来,等于把备份入口一并打掉(同类伤疤见 +// httpapi 的 TestUpdateProfileCodeRequiredKeepsRulesExportUsable)。 +// 写入前才该拦的检查放在 ValidateForImport。 func (p *RulePack) Validate() error { if p.Format != FormatName { return fmt.Errorf("%w: format=%q,期望 %q", ErrNotRulePack, p.Format, FormatName) @@ -92,6 +99,31 @@ func (p *RulePack) Validate() error { return nil } +// ValidateForImport 是导入侧在 Validate 之外额外拦下的「这份配置根本算不出分」检查。 +// +// 目前只有一条:分值表的档位超出录取档位数(awardRanks)。见 AwardRanksOverflow。 +// 单独成一个方法而不是塞进 Validate,是因为 Validate 同时被导出路径用着——历史库里已经存在的 +// 越档模板必须照样能导出(否则备份与规则导出会 500),但绝不能再从导入这条路进来。 +func (p *RulePack) ValidateForImport() error { + var l issueList + for i, prof := range p.Profiles { + maxRank := 0 + for _, r := range prof.Rules { + if r.Rank > maxRank { + maxRank = r.Rank + } + } + if msg := AwardRanksOverflow(prof.AwardRanks, maxRank); msg != "" { + // 走 %s 而不是把 msg 当格式串:文案以后加个百分号就会把消息打坏。 + l.addf(fmt.Sprintf("profiles[%d].awardRanks", i), "%s", msg) + } + } + if len(l.issues) > 0 { + return &ValidationError{Issues: l.issues} + } + return nil +} + func validateTieBreak(l *issueList, tieBreak []string) { seen := make(map[string]bool, len(tieBreak)) for i, key := range tieBreak { @@ -145,6 +177,24 @@ func validateProfile(l *issueList, path string, prof Profile, seen map[string]bo } } +// AwardRanksOverflow 在「分值表的档位超出录取档位数」时返回一句人话说明,没问题时返回空串。 +// +// 为什么要拦:算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints), +// 于是「分值表配到第 10 档、录取名次还留着 6」这种配置能安静落库——第 7~10 档在模板页上看着有分, +// 算出来恒为 0,榜上没有任何异常提示(审查 01 P1)。 +// 模板保存(httpapi/repo.go 的 validateProfileAwardCoverage)与规则包导入(ValidateForImport) +// 共用这一句说明:同一份配置在两条路上必须得到同样的说法,现场才能照着改。 +// +// awardRanks <= 0(0 表示不按名次封顶)是既有语义,一律放行,并在文案里把这个出口告诉用户。 +func AwardRanksOverflow(awardRanks, maxRuleRank int) string { + if awardRanks <= 0 || maxRuleRank <= awardRanks { + return "" + } + return fmt.Sprintf("你配了第 %d 档,但录取名次(awardRanks)只到第 %d 档,第 %d 档及以后不会计分:"+ + "要么把录取名次改成 %d 或 0(0 表示不按名次封顶),要么删掉第 %d 档之后的档位", + maxRuleRank, awardRanks, awardRanks+1, maxRuleRank, awardRanks) +} + func validateRecordBonus(l *issueList, rb RecordBonus) { if rb.Mode != "" && rb.Mode != domain.RecordBonusMax && rb.Mode != domain.RecordBonusSum { l.addf("recordBonus.mode", "只能是 max / sum,当前 %q", rb.Mode) @@ -226,6 +276,11 @@ func validateChannels(l *issueList, path string, channels []Channel, profiles ma } else if !profiles[ch.Profile] { l.addf(cpath+".profile", "引用了不存在的积分模板 %q", ch.Profile) } + // 汇总口径只有 avg / sum 两个取值;不写 = avg(缺省),拼错的取值必须报错而不是被当成 avg。 + if ch.AggregateMode != "" && + ch.AggregateMode != string(domain.AggregateAvg) && ch.AggregateMode != string(domain.AggregateSum) { + l.addf(cpath+".aggregateMode", "只能是 avg / sum,当前 %q", ch.AggregateMode) + } // 落库有 UNIQUE(event_id, scope, basis) 约束,这里先挡住,避免导入时才炸。 key := string(ch.Scope) + "/" + string(ch.Basis) if seen[key] { diff --git a/server/migrations/event/0022_void_historical_orphan_ledger.sql b/server/migrations/event/0022_void_historical_orphan_ledger.sql new file mode 100644 index 0000000..70bf3b3 --- /dev/null +++ b/server/migrations/event/0022_void_historical_orphan_ledger.sql @@ -0,0 +1,27 @@ +-- 清理升级前留下的「孤儿流水」:删掉一个「只有名次、没有报名项」的小项时 +-- (球类那种形态,名次直接按单位录进 channel_manual_ranks),名下的成绩流水 +-- 被 score_ledger.event_id 的 ON DELETE SET NULL 置空,而 voided 仍然是 0 —— +-- 分数留在榜上,此后任何按 event_id 的重算都再也打不到这些行,只能改库。 +-- +-- 写侧已经补上了「删小项时在同一事务里作废」(httpapi/repo_events.go 的 deleteEvent), +-- 但**升级前已经产生**的那批不会被自动清掉,现场确认删过带分的小项。这条迁移就是那一次性清理。 +-- +-- 只作废 source_type IN ('result', 'record_bonus'): +-- - 这两类流水全部由计分引擎产出,引擎一直把 EventID 写进库(domain/scoring.go), +-- 所以「event_id 为空」只可能来自小项被删后的置空,即孤儿; +-- - 人工调整(source_type = 'adjustment')**合法地**就是 event_id 为 NULL +-- (写入口 httpapi/repo_misc.go 的 createAdjustment,见 domain/ledger.go 的说明), +-- 一刀切会把合法的调整分一起吞掉。 +-- +-- 作废而不是删除:流水行要留着当审计痕迹(voided_at + void_reason 说明「谁也没做,是升级清理」)。 +-- voided_by 刻意留 NULL:这次清理没有人操作,编一个操作人就是假账。 +-- +-- 幂等:条件里的 voided = 0 让重复执行只剩空更新;迁移本身还由 schema_migrations 记账只放行一次, +-- 所以「重复启动 / 手工重放这条 SQL」都不会重复作废,也不会覆盖已有流水的作废时间与理由。 +UPDATE score_ledger + SET voided = 1, + voided_at = datetime('now'), + void_reason = 'event_id 已失效(历史孤儿)' + WHERE voided = 0 + AND event_id IS NULL + AND source_type IN ('result', 'record_bonus'); diff --git a/server/migrations/event/0023_channel_aggregate_mode.sql b/server/migrations/event/0023_channel_aggregate_mode.sql new file mode 100644 index 0000000..99f571a --- /dev/null +++ b/server/migrations/event/0023_channel_aggregate_mode.sql @@ -0,0 +1,13 @@ +-- 聚合通道(basis=aggregate)的汇总口径。 +-- +-- 背景:团体分要能现场在「按成员平均」与「按成员总和」之间切。此前只有平均值一种口径 +-- (平均值内部的分母口径是 0017 加的 events.avg_mode),这里把「汇总方式」按通道存下来。 +-- +-- 只加列、不动既有数据行:老通道一律取默认值 'avg',升级前后榜单一分不差。 +-- 取值: +-- avg:团体成绩 = 该主体成员成绩的平均值(缺省,与升级前行为一致); +-- sum:团体成绩 = 该主体成员成绩的总和。 +-- 汇总口径与 avg_mode 是两个维度:sum 下 avg_mode 只决定「一个有效成绩都没有的单位 +-- 算不算 0 分参与排名」(valid 不参与 / all 参与),见 domain/scoring.go 的 scoreByAggregate。 +ALTER TABLE event_score_channels ADD COLUMN aggregate_mode TEXT NOT NULL DEFAULT 'avg' + CHECK (aggregate_mode IN ('avg', 'sum')); diff --git a/server/migrations/event/migration_0022_test.go b/server/migrations/event/migration_0022_test.go new file mode 100644 index 0000000..5739ea4 --- /dev/null +++ b/server/migrations/event/migration_0022_test.go @@ -0,0 +1,262 @@ +package event + +import ( + "context" + "database/sql" + "io" + "io/fs" + "log/slog" + "reflect" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/realtime" + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" + "git.yeij.top/AskaEth/Scoreboard/server/migrations" +) + +// 孤儿流水的统一口径:计分引擎产出的成绩分与破纪录加分,但 event_id 已被置空。 +const orphanLedgerCond = `voided = 0 AND event_id IS NULL AND source_type IN ('result', 'record_bonus')` + +// 0022 一次性清理升级前留下的孤儿流水:删掉「只有名次、没有报名项」的小项时, +// score_ledger.event_id 被 ON DELETE SET NULL 置空而 voided 仍是 0,分数留在榜上、 +// 之后任何按 event_id 的重算都打不到这些行。写侧(deleteEvent)已补上作废,这条迁移管历史那批。 +// +// 三条底线: +// - 只清 result / record_bonus。人工调整合法地就是 event_id 为 NULL,一刀切会吞掉合法调整分; +// - 作废而不是删除,留 voided_at + void_reason 当审计痕迹,且不能覆盖已有的作废理由与时间; +// - 幂等:连跑两次,榜与每一行都必须一模一样。 +func TestMigration0022VoidsHistoricalOrphanLedger(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0022") // 回到「升级前」:库结构到 0021 为止,0022 还没跑过 + + // 造现场:E1 是还在的小项;E2 是那种「只有团体名次、没有报名项」的小项,被删掉过。 + stmts := []string{ + `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E1', '100米跑', 'individual', 'auto', 'time', 'asc', 'published')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, status) + VALUES (1, 'E2', '篮球', 'team', 'manual', 'none', 'none', 'published')`, + // 行 1:健康流水,挂在还在的 E1 上。 + `INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points) + VALUES (1, 1, 'result', 1, 10)`, + // 行 2、3:升级前的孤儿——删 E2 时 event_id 被置空、voided 还是 0(这条迁移要清的就是它们)。 + `INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points) + VALUES (1, 2, 'result', 2, 4)`, + `INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points) + VALUES (1, 2, 'record_bonus', 2, 2)`, + // 行 4:合法的人工调整,event_id 本来就允许为空,必须原样保留(防误伤的关键一条)。 + `INSERT INTO score_ledger(team_id, source_type, source_id, points) + VALUES (1, 'adjustment', 9, 7)`, + // 行 5:升级前已经被正确作废过的孤儿,理由/时间都不能被这次清理改写。 + `INSERT INTO score_ledger(team_id, event_id, source_type, source_id, points, voided, voided_at, void_reason) + VALUES (1, 2, 'result', 3, 1, 1, '2026-09-01 08:00:00', '小项删除,本小项积分流水一并作废')`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造升级前数据 %q: %v", s, err) + } + } + // 旧删除路径:老 deleteEvent 只删小项本身(子表先删),流水只被外键置空。 + if _, err := db.Exec(`DELETE FROM events WHERE id = 2`); err != nil { + t.Fatalf("按旧路径删除小项 E2: %v", err) + } + + // 升级前:两条孤儿还活着,且分真的挂在榜上(这就是现场看到的错榜)。 + if got := queryInt(t, db, `SELECT count(*) FROM score_ledger WHERE `+orphanLedgerCond); got != 2 { + t.Fatalf("升级前应有 2 条孤儿流水,得到 %d", got) + } + if got := boardPoints(t, db, 1); got != 23 { + t.Fatalf("升级前孤儿分应还在榜上(10+4+2+7=23),得到 %v", got) + } + + // 走真实升级路径(main.go 打开旧库后补跑的就是它),跑两次验幂等。 + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("第 1 次迁移: %v", err) + } + afterFirst := dumpLedger(t, db) + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("第 2 次迁移: %v", err) + } + if got := dumpLedger(t, db); !reflect.DeepEqual(got, afterFirst) { + t.Errorf("重复迁移改动了流水:\n第一次 %+v\n第二次 %+v", afterFirst, got) + } + if got, want := countRows(t, db, "schema_migrations"), migrationCount(t); got != want { + t.Errorf("迁移登记数 = %d,期望 %d", got, want) + } + // 幂等还要经得起「迁移被重放」:store.migrate 的注释写明,schema_migrations 少一行 + // (手工删行 / 从别处拷来的库)就会把跑过的迁移再跑一遍。这里直接重放这条 SQL 的正文, + // 结果必须与第一次一致——不改动分值,也不重写已作废流水的作废时间。 + if _, err := db.Exec(readMigration(t, "0022")); err != nil { + t.Fatalf("重放 0022: %v", err) + } + if got := dumpLedger(t, db); !reflect.DeepEqual(got, afterFirst) { + t.Errorf("重放 0022 改动了流水:\n重放前 %+v\n重放后 %+v", afterFirst, got) + } + + // 作废是作废、不是删除:五行流水一行不少。 + if got := countRows(t, db, "score_ledger"); got != 5 { + t.Fatalf("升级后流水行数 = %d,期望 5(作废要留审计痕迹,不能删行)", got) + } + if got := queryInt(t, db, `SELECT count(*) FROM score_ledger WHERE `+orphanLedgerCond); got != 0 { + t.Errorf("升级后仍剩 %d 条存活孤儿流水", got) + } + + // 工单①:行 2、3 两条孤儿被作废,且写明时间与原因;voided_by 留空表示「不是人做的,是升级清理」。 + for _, id := range []int64{2, 3} { + var ( + voided int + voidedBy sql.NullInt64 + voidedAt sql.NullString + voidReason sql.NullString + ) + if err := db.QueryRow(`SELECT voided, voided_by, voided_at, void_reason FROM score_ledger WHERE id = ?`, id). + Scan(&voided, &voidedBy, &voidedAt, &voidReason); err != nil { + t.Fatalf("读流水 %d: %v", id, err) + } + if voided != 1 { + t.Errorf("孤儿流水 %d 应被作废,voided = %d", id, voided) + } + if !voidedAt.Valid || voidedAt.String == "" { + t.Errorf("孤儿流水 %d 缺少作废时间: %+v", id, voidedAt) + } + if !voidReason.Valid || voidReason.String != "event_id 已失效(历史孤儿)" { + t.Errorf("孤儿流水 %d 的作废原因不对: %+v", id, voidReason) + } + if voidedBy.Valid { + t.Errorf("孤儿流水 %d 的 voided_by 应留空(没有操作人),得到 %d", id, voidedBy.Int64) + } + } + + // 工单②:合法调整分原样保留——没被作废、没有理由、分值不变。 + var ( + points float64 + voided int + voidedAt sql.NullString + voidReason sql.NullString + ) + if err := db.QueryRow(`SELECT points, voided, voided_at, void_reason FROM score_ledger WHERE id = 4`). + Scan(&points, &voided, &voidedAt, &voidReason); err != nil { + t.Fatalf("读调整流水: %v", err) + } + if points != 7 || voided != 0 || voidedAt.Valid || voidReason.Valid { + t.Errorf("人工调整被误伤: points=%v voided=%d voided_at=%+v void_reason=%+v", points, voided, voidedAt, voidReason) + } + // 行 1:健康流水(小项还在)不受影响。 + if got := queryInt(t, db, `SELECT voided FROM score_ledger WHERE id = 1`); got != 0 { + t.Errorf("挂在还活着的小项上的流水被误作废: voided = %d", got) + } + // 行 5:早已作废的那条保留自己的理由与时间,不被这次清理重写。 + var ( + oldAt sql.NullString + oldReason sql.NullString + ) + if err := db.QueryRow(`SELECT voided_at, void_reason FROM score_ledger WHERE id = 5`).Scan(&oldAt, &oldReason); err != nil { + t.Fatalf("读老作废流水: %v", err) + } + if !oldAt.Valid || oldAt.String != "2026-09-01 08:00:00" || + !oldReason.Valid || oldReason.String != "小项删除,本小项积分流水一并作废" { + t.Errorf("老作废痕迹被改写: voided_at=%+v void_reason=%+v", oldAt, oldReason) + } + + // 榜单:孤儿分(4 与 2)消失,只留下健康分 10 与合法调整 7。连跑两次后仍是 17。 + if got := boardPoints(t, db, 1); got != 17 { + t.Errorf("升级后单位总分应只剩 10+7=17,得到 %v", got) + } + + fkRows, err := db.Query(`PRAGMA foreign_key_check`) + if err != nil { + t.Fatalf("外键检查: %v", err) + } + defer func() { _ = fkRows.Close() }() + if fkRows.Next() { + t.Error("清理后存在外键悬挂") + } +} + +// readMigration 取出嵌入的赛事库迁移正文(按文件名前缀找), +// 用于在测试里直接重放某一条迁移,验证 SQL 自身的幂等。 +func readMigration(t *testing.T, prefix string) string { + t.Helper() + + fsys, err := fs.Sub(migrations.EventFS, "event") + if err != nil { + t.Fatalf("定位迁移目录: %v", err) + } + names, err := fs.Glob(fsys, prefix+"*.sql") + if err != nil { + t.Fatalf("列出迁移: %v", err) + } + if len(names) != 1 { + t.Fatalf("迁移 %s*.sql 命中 %d 个文件: %v", prefix, len(names), names) + } + body, err := fs.ReadFile(fsys, names[0]) + if err != nil { + t.Fatalf("读取迁移 %s: %v", names[0], err) + } + return string(body) +} + +// boardPoints 走真实的大屏读路径(实时快照)取某单位的总分: +// 「孤儿分是否还挂在榜上」要按大屏看到的口径判定,而不是数 score_ledger 的行数。 +func boardPoints(t *testing.T, db *sql.DB, teamID int64) float64 { + t.Helper() + + hub := realtime.NewHub(db, slog.New(slog.NewTextHandler(io.Discard, nil))) + defer func() { _ = hub.Close() }() + snap, err := hub.Snapshot(context.Background()) + if err != nil { + t.Fatalf("取榜单快照: %v", err) + } + for _, row := range snap.Standings { + if row.TeamID == teamID { + return row.TotalPoints + } + } + t.Fatalf("榜单里没有单位 %d: %+v", teamID, snap.Standings) + return 0 +} + +// ledgerRow 是流水行的完整快照,用于比对「连跑两次」有没有改动任何一格。 +type ledgerRow struct { + id int64 + points float64 + voided int + voidedBy sql.NullInt64 + voidedAt sql.NullString + voidReason sql.NullString +} + +func dumpLedger(t *testing.T, db *sql.DB) []ledgerRow { + t.Helper() + + rows, err := db.Query(`SELECT id, points, voided, voided_by, voided_at, void_reason + FROM score_ledger ORDER BY id`) + if err != nil { + t.Fatalf("读流水: %v", err) + } + defer func() { _ = rows.Close() }() + + var out []ledgerRow + for rows.Next() { + var r ledgerRow + if err := rows.Scan(&r.id, &r.points, &r.voided, &r.voidedBy, &r.voidedAt, &r.voidReason); err != nil { + t.Fatalf("解析流水: %v", err) + } + out = append(out, r) + } + if err := rows.Err(); err != nil { + t.Fatalf("遍历流水: %v", err) + } + return out +} + +func queryInt(t *testing.T, db *sql.DB, query string, args ...any) int { + t.Helper() + + var n int + if err := db.QueryRow(query, args...).Scan(&n); err != nil { + t.Fatalf("查询 %q: %v", query, err) + } + return n +} diff --git a/server/migrations/event/migration_0023_test.go b/server/migrations/event/migration_0023_test.go new file mode 100644 index 0000000..c518511 --- /dev/null +++ b/server/migrations/event/migration_0023_test.go @@ -0,0 +1,99 @@ +package event + +import ( + "database/sql" + "strings" + "testing" + + "git.yeij.top/AskaEth/Scoreboard/server/internal/store" +) + +// 0023:聚合通道(basis=aggregate)的汇总口径 aggregate_mode。 +// +// 三条底线: +// - 老库升级后每一行都拿到默认值 avg —— 升级前只有「按成员平均」一种口径,榜单必须一分不差; +// - 其它列与行数一行不动(这是纯加列迁移); +// - 取值只允许 avg / sum,拼错的取值在写侧就被 CHECK 挡下; +// - 幂等:连跑两次不报错、不重复登记。 +func TestMigration0023ChannelAggregateMode(t *testing.T) { + db := openEventDB(t) + applyBefore(t, db, "0023") // 回到「升级前」:库结构到 0022 为止 + + var teamProfile int64 + if err := db.QueryRow(`SELECT id FROM scoring_profiles WHERE code = 'team_top6'`).Scan(&teamProfile); err != nil { + t.Fatalf("读取团体积分模板: %v", err) + } + stmts := []string{ + `INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`, + `INSERT INTO sports(code, name) VALUES ('SP1', '田径')`, + `INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status) + VALUES (1, 'TEAM_PULLUP', '团体引体向上', 'individual_team', 'auto', 'count', 'desc', 1, 2, 'published')`, + } + for _, s := range stmts { + if _, err := db.Exec(s); err != nil { + t.Fatalf("造老库数据 %q: %v", s, err) + } + } + if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, count_to_team) + VALUES (1, 'team', 'aggregate', ?, 1, 1)`, teamProfile); err != nil { + t.Fatalf("造老库通道: %v", err) + } + + // 升级前:这一列还不该存在。 + var probe sql.NullString + if err := db.QueryRow(`SELECT aggregate_mode FROM event_score_channels`).Scan(&probe); err == nil { + t.Fatal("升级前不应存在 aggregate_mode 列") + } else if !strings.Contains(err.Error(), "aggregate_mode") { + t.Fatalf("查询报错应点明缺列 aggregate_mode,实际 %v", err) + } + + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("升级赛事库: %v", err) + } + + // 老行取默认值 avg,其它列原样。 + var ( + scope, basis, mode string + countToTeam int + sortOrder int + ) + if err := db.QueryRow(`SELECT scope, basis, aggregate_mode, count_to_team, sort_order + FROM event_score_channels WHERE event_id = 1`). + Scan(&scope, &basis, &mode, &countToTeam, &sortOrder); err != nil { + t.Fatalf("读取升级后的通道: %v", err) + } + if scope != "team" || basis != "aggregate" || countToTeam != 1 || sortOrder != 1 { + t.Fatalf("升级不应改动既有列:scope=%q basis=%q count_to_team=%d sort_order=%d", scope, basis, countToTeam, sortOrder) + } + if mode != "avg" { + t.Fatalf("老通道的汇总口径 = %q,期望默认值 avg(等于升级前的「按成员平均」)", mode) + } + + // 取值只有 avg / sum:拼错的必须被 CHECK 挡下,而不是静默当成某个口径。 + if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) + VALUES (1, 'athlete', 'aggregate', ?, 2, 'biggest')`, teamProfile); err == nil { + t.Fatal("非法 aggregate_mode 应被 CHECK 拒绝") + } + if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode) + VALUES (1, 'athlete', 'aggregate', ?, 2, 'sum')`, teamProfile); err != nil { + t.Fatalf("写入 sum 口径应成功: %v", err) + } + + // 幂等:再跑一次不报错、行数不变、登记仍只有一条。 + if err := store.MigrateEvent(db); err != nil { + t.Fatalf("重复升级应无副作用: %v", err) + } + var rows, registered int + if err := db.QueryRow(`SELECT COUNT(*) FROM event_score_channels`).Scan(&rows); err != nil { + t.Fatalf("统计通道: %v", err) + } + if rows != 2 { + t.Fatalf("通道数 = %d,期望 2", rows) + } + if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE name = '0023_channel_aggregate_mode.sql'`).Scan(®istered); err != nil { + t.Fatalf("查询迁移登记: %v", err) + } + if registered != 1 { + t.Fatalf("迁移登记数 = %d,期望 1", registered) + } +} diff --git a/web/src/admin/api/mock.ts b/web/src/admin/api/mock.ts index 8079511..7235d5c 100644 --- a/web/src/admin/api/mock.ts +++ b/web/src/admin/api/mock.ts @@ -697,25 +697,40 @@ class MockDb { } } - return this.teams - .map((team) => { - const bucket = totals.get(team.id) ?? { points: 0, gold: 0, silver: 0, bronze: 0 } - return { - teamId: team.id, - teamCode: team.code, - teamName: team.name, - shortName: team.shortName, - color: team.color, - logoPath: team.logoPath, - rank: 0, - gold: bucket.gold, - silver: bucket.silver, - bronze: bucket.bronze, - medalTotal: bucket.gold + bucket.silver + bucket.bronze, - totalPoints: round(bucket.points), - rankChange: null, - } - }) + const rows: StandingRow[] = this.teams.map((team) => { + const bucket = totals.get(team.id) ?? { points: 0, gold: 0, silver: 0, bronze: 0 } + return { + teamId: team.id, + teamCode: team.code, + teamName: team.name, + shortName: team.shortName, + color: team.color, + logoPath: team.logoPath, + rank: 0, + medalRank: 0, + gold: bucket.gold, + silver: bucket.silver, + bronze: bucket.bronze, + medalTotal: bucket.gold + bucket.silver + bucket.bronze, + totalPoints: round(bucket.points), + rankChange: null, + } + }) + + // 单位奖牌榜的名次与总积分榜无关(金 → 银 → 铜 → 单位编码),示例数据照服务端同一口径算, + // 数组顺序仍保持积分榜顺序(服务端就是这么下发的:rank 管顺序,medalRank 单独带)。 + const medalRank = new Map() + ;[...rows] + .sort( + (a, b) => + b.gold - a.gold || + b.silver - a.silver || + b.bronze - a.bronze || + a.teamCode.localeCompare(b.teamCode), + ) + .forEach((row, index) => medalRank.set(row.teamId, index + 1)) + + return rows .sort( (a, b) => b.totalPoints - a.totalPoints || @@ -724,7 +739,7 @@ class MockDb { b.bronze - a.bronze || a.teamCode.localeCompare(b.teamCode), ) - .map((row, index) => ({ ...row, rank: index + 1 })) + .map((row, index) => ({ ...row, rank: index + 1, medalRank: medalRank.get(row.teamId) ?? index + 1 })) } athleteStandings(): AthleteStanding[] { @@ -784,6 +799,12 @@ class MockDb { resultText: result.resultText, points, createdAt: result.recordedAt ?? new Date().toISOString(), + // 示例数据没有版本链(result_revisions 只有真实服务端才写),四个字段一律 null: + // 追不出来就显示为空,界面正好演示「没改过」那一档,不编一个假的修订次数。 + revisionCount: null, + lastRevisionAt: null, + lastRevisionReason: null, + lastRevisedBy: null, }) } } diff --git a/web/src/admin/api/scoring.ts b/web/src/admin/api/scoring.ts index 29595f1..915648a 100644 --- a/web/src/admin/api/scoring.ts +++ b/web/src/admin/api/scoring.ts @@ -1,4 +1,4 @@ -import { normalizeAvgMode, normalizeCountToTeam } from '@/shared' +import { normalizeAggregateMode, normalizeAvgMode, normalizeCountToTeam } from '@/shared' import type { AvgMode, ChannelBasis, @@ -182,16 +182,20 @@ function sortManual(cands: Candidate[]): void { } /** - * 聚合通道:先把同一主体的成绩取平均,再按平均值排名(scoring.go scoreByAggregate)。 + * 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和),再按汇总值排名 + * (scoring.go scoreByAggregate)。 * - * 分母由 avgMode 决定:valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去, - * 没成绩的按 0 计入。scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。 + * 汇总口径来自通道的 aggregateMode(缺省 avg);平均值分母由 avgMode 决定: + * valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,没成绩的按 0 计入。 + * sum 只累加有效成绩,avgMode 在这里只决定「一个有效成绩都没有的主体算不算 0 分参与排名」。 + * scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。 */ function aggregateCandidates( rows: readonly ScorableRow[], channel: ScoreChannel, avgMode: AvgMode, ): Candidate[] { + const sumMode = normalizeAggregateMode(channel.aggregateMode) === 'sum' const order: number[] = [] const buckets = new Map() for (const row of rows) { @@ -200,7 +204,7 @@ function aggregateCandidates( const value = row.status === 'ok' ? row.resultValue : null let bucket = buckets.get(subject) if (bucket === undefined) { - // valid 口径下没有任何有效成绩的主体不参与排名。 + // valid 口径下没有任何有效成绩的主体不参与排名(两种汇总口径都一样)。 if (value === null && avgMode !== 'all') continue bucket = { row, total: 0, valid: 0, registered: 0 } buckets.set(subject, bucket) @@ -220,9 +224,10 @@ function aggregateCandidates( registered: number } const count = avgMode === 'all' ? bucket.registered : bucket.valid + const value = sumMode ? bucket.total : count > 0 ? bucket.total / count : 0 return { row: bucket.row, - value: count > 0 ? bucket.total / count : 0, + value, rankIn: 0, subject, teamId: bucket.row.teamId, diff --git a/web/src/admin/components/baseData/ChannelsSection.vue b/web/src/admin/components/baseData/ChannelsSection.vue index cd3e68b..eea7a8a 100644 --- a/web/src/admin/components/baseData/ChannelsSection.vue +++ b/web/src/admin/components/baseData/ChannelsSection.vue @@ -1,12 +1,18 @@ @@ -55,6 +58,7 @@ const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0)
项目 / 组别 + 运动员 名次 成绩 积分 @@ -63,10 +67,20 @@ const autoClose = computed(() => (detail.value?.autoCloseSeconds ?? 0) > 0) {{ item.eventName ?? '人工调整' }} {{ item.categoryName }} + + {{ breakdownSourceTag(item) }} + {{ breakdownAthleteName(item, teamName) }} {{ item.rank ?? '—' }} {{ item.resultText ?? '—' }} {{ item.points }} + + + {{ breakdownRevisionLine(item) }} +
@@ -142,7 +156,10 @@ h2 { } .row { display: grid; - grid-template-columns: 1fr 110px 160px 130px; + /* 覆盖层是 16:9 屏幕右侧的一半:五列都要在 960px 内读得清,姓名列给得最宽。 + 后四列按内容定宽(名次只有一位数、积分两三位),省下来的宽度全给「项目 / 组别」—— + 破纪录加分那一行还要在项目名后面挤下一个来源标签(见 .col-source)。 */ + grid-template-columns: minmax(0, 1fr) 190px 76px 140px 96px; align-items: center; gap: 12px; padding: 14px 8px; @@ -160,6 +177,14 @@ h2 { text-align: right; font-variant-numeric: tabular-nums; } +/* 项目名 / 运动员名(团体项目退回单位名)可能很长:省略号收尾,不把名次成绩挤出行外。 */ +.col-name, +.col-athlete { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .col-points { color: var(--gold); font-weight: 700; @@ -170,6 +195,25 @@ h2 { font-size: 20px; color: var(--text-dim); } +/* 破纪录加分 / 人工调整这类「非普通成绩」的来源标签:跟在项目名后面,不单占一列。 */ +.col-name em.col-source { + padding: 2px 10px; + border-radius: 8px; + border: 1px solid var(--primary-line); + font-size: 19px; + color: var(--primary-text); +} +/* 修订痕迹:整行小字,比正文矮一号、颜色更淡,不跟成绩抢注意力。 */ +.col-revision { + grid-column: 1 / -1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 20px; + letter-spacing: 1px; + color: var(--text-dim); +} .empty { flex: 1; display: grid; diff --git a/web/src/display/components/TickerBar.vue b/web/src/display/components/TickerBar.vue index e17d893..4759746 100644 --- a/web/src/display/components/TickerBar.vue +++ b/web/src/display/components/TickerBar.vue @@ -1,5 +1,6 @@ @@ -25,7 +27,7 @@ const rowFont = computed(() => fitRowFont(rows.value.length, BOARD_AVAILABLE_PX) 合计
- {{ row.rank }} + {{ row.medalRank }} {{ row.teamName }} diff --git a/web/src/display/guards.ts b/web/src/display/guards.ts index 1efa27e..f08fde2 100644 --- a/web/src/display/guards.ts +++ b/web/src/display/guards.ts @@ -194,9 +194,12 @@ export function parseStandingRow(source: unknown): StandingRow { const gold = num(rec.gold) const silver = num(rec.silver) const bronze = num(rec.bronze) + const rank = num(rec.rank) return { ...parseTeamRef(rec), - rank: num(rec.rank), + rank, + // 单位奖牌榜名次独立于总积分榜(金→银→铜);旧服务端不下发它,退回总榜名次(旧行为)。 + medalRank: num(rec.medalRank, rank), gold, silver, bronze, @@ -542,6 +545,11 @@ export function parseBreakdownItem(source: unknown): BreakdownItem { resultText: strOrNull(rec.resultText), points: num(rec.points), createdAt: str(rec.createdAt), + // 修订痕迹:老服务端不下发,一律解析成 null(= 追不出来),界面据此不显示角标。 + revisionCount: numOrNull(rec.revisionCount), + lastRevisionAt: strOrNull(rec.lastRevisionAt), + lastRevisionReason: strOrNull(rec.lastRevisionReason), + lastRevisedBy: strOrNull(rec.lastRevisedBy), } } diff --git a/web/src/display/mock.ts b/web/src/display/mock.ts index 24f21e5..ed5abb3 100644 --- a/web/src/display/mock.ts +++ b/web/src/display/mock.ts @@ -70,7 +70,7 @@ const STANDING_SEEDS: readonly StandingSeed[] = [ ] function buildStandings(): StandingRow[] { - return MOCK_TEAMS.map((item, index) => { + const rows: StandingRow[] = MOCK_TEAMS.map((item, index) => { const seed = STANDING_SEEDS[index] ?? STANDING_SEEDS[STANDING_SEEDS.length - 1] const row = seed ?? { rank: index + 1, totalPoints: 0, gold: 0, silver: 0, bronze: 0, rankChange: null } return { @@ -81,6 +81,7 @@ function buildStandings(): StandingRow[] { color: item.color, logoPath: item.logoPath, rank: row.rank, + medalRank: 0, gold: row.gold, silver: row.silver, bronze: row.bronze, @@ -89,6 +90,20 @@ function buildStandings(): StandingRow[] { rankChange: row.rankChange, } }) + + // 单位奖牌榜的名次独立于总积分榜(金 → 银 → 铜 → 单位编码);示例数据照服务端同一口径算, + // 数组顺序仍保持积分榜顺序(服务端就是这么下发的:rank 管顺序,medalRank 单独带)。 + const medalRank = new Map() + ;[...rows] + .sort( + (a, b) => + b.gold - a.gold || + b.silver - a.silver || + b.bronze - a.bronze || + a.teamCode.localeCompare(b.teamCode), + ) + .forEach((row, index) => medalRank.set(row.teamId, index + 1)) + return rows.map((row) => ({ ...row, medalRank: medalRank.get(row.teamId) ?? row.rank })) } function buildEvents(): Event[] { diff --git a/web/src/display/tests/detail-overlay-athlete.test.ts b/web/src/display/tests/detail-overlay-athlete.test.ts new file mode 100644 index 0000000..dc14326 --- /dev/null +++ b/web/src/display/tests/detail-overlay-athlete.test.ts @@ -0,0 +1,174 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { parse } from 'vue/compiler-sfc' +import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag, type BreakdownItem } from '@/shared' + +/** + * 大屏积分明细覆盖层的「运动员」列。 + * + * 现场裁判长的原话:推到大屏的明细只有项目 / 名次 / 成绩 / 积分,看不出「谁在哪个项目拿了多少分」, + * 这种明细说服不了观众单位名字为什么升上去。数据(athleteName)服务端一直有,是覆盖层漏了这一列。 + * + * 项目没有 jsdom 与组件测试工具,这里沿用仓库既有做法:`.vue` 模板用源码契约断言 + * (列头顺序、每个格子绑了什么),文案内容交给 `breakdownAthleteName` 这个纯函数用例守。 + */ + +const OVERLAY_SOURCE = readFileSync(new URL('../components/DetailOverlay.vue', import.meta.url), 'utf8') + +/** 取模板里以某个固定串开头、到下一个
为止的片段(这些行里没有嵌套 div)。 */ +function block(startPattern: RegExp): string { + const found = startPattern.exec(OVERLAY_SOURCE) + assert.ok(found !== null, `模板里应有 ${startPattern}`) + const rest = OVERLAY_SOURCE.slice(found.index) + const end = rest.indexOf('') + assert.ok(end > 0, '行片段应以 结束') + return rest.slice(0, end) +} + +interface Cell { + cls: string + inner: string +} + +function cellsOf(fragment: string): Cell[] { + return [...fragment.matchAll(/([\s\S]*?)<\/span>/g)].map((match) => ({ + cls: match[1] ?? '', + inner: (match[2] ?? '').replace(/\s+/g, ' ').trim(), + })) +} + +const HEAD_ROW = block(/
/) +const BODY_ROW = block(/
/) + +function styleText(source: string): string { + const { descriptor } = parse(source, { filename: 'style.vue' }) + return descriptor.styles[0]?.content ?? '' +} + +function cssRule(style: string, selector: string): string { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(style) + assert.ok(match !== null, `样式里应存在 ${selector} 规则`) + return match[1] ?? '' +} + +function item(over: Partial = {}): BreakdownItem { + return { + ledgerId: 1, + sourceType: 'result', + eventId: 1, + eventCode: 'M100', + eventName: '100米跑', + categoryName: '男子', + athleteId: 7, + athleteName: '张伟', + rank: 1, + resultText: '11.58', + points: 10, + createdAt: '2026-09-13 10:00:00', + revisionCount: null, + lastRevisionAt: null, + lastRevisionReason: null, + lastRevisedBy: null, + ...over, + } +} + +test('列头按「项目/组别 · 运动员 · 名次 · 成绩 · 积分」排列,运动员插在项目与名次之间', () => { + const labels = [...HEAD_ROW.matchAll(/([^<]*)<\/span>/g)].map((match) => + (match[1] ?? '').trim(), + ) + assert.deepEqual(labels, ['项目 / 组别', '运动员', '名次', '成绩', '积分']) +}) + +test('明细行渲染运动员格,绑定的是团队共用的降级函数', () => { + const cells = cellsOf(BODY_ROW) + assert.deepEqual( + cells.map((cell) => cell.cls), + ['col-name', 'col-athlete', 'col-rank', 'col-result', 'col-points'], + ) + assert.equal(cells[1]?.inner, '{{ breakdownAthleteName(item, teamName) }}') +}) + +test('项目 / 名次 / 成绩 / 积分四列原样保留(是加列不是改列)', () => { + const cells = cellsOf(BODY_ROW) + assert.match(cells[0]?.inner ?? '', /item\.eventName \?\? '人工调整'/) + assert.equal(cells[2]?.inner, "{{ item.rank ?? '—' }}") + assert.equal(cells[3]?.inner, "{{ item.resultText ?? '—' }}") + assert.equal(cells[4]?.inner, '{{ item.points }}') +}) + +test('个人流水那一行大屏上看得见是谁:运动员格是姓名,四个老格子照旧', () => { + const row = item() + const rendered = [ + row.eventName ?? '人工调整', + breakdownAthleteName(row, '第一分公司'), + row.rank ?? '—', + row.resultText ?? '—', + row.points, + ] + assert.deepEqual(rendered, ['100米跑', '张伟', 1, '11.58', 10]) +}) + +test('团体项目流水没有运动员名:降级成单位名,不出现空白格或 null', () => { + const row = item({ athleteName: null, athleteId: null, sourceType: 'result' }) + const cell = breakdownAthleteName(row, '第一分公司') + assert.equal(cell, '第一分公司') + assert.doesNotMatch(cell, /null|undefined/) +}) + +test('覆盖层是 16:9 右半屏:五列加间距放得下,姓名列留得够宽', () => { + const row = cssRule(styleText(OVERLAY_SOURCE), '.row') + const tracks = /grid-template-columns:\s*([^;]+);/.exec(row) + assert.ok(tracks !== null, '.row 应声明 grid-template-columns') + const parts = (tracks[1] ?? '').trim().split(/\s+(?![^(]*\))/) + assert.equal(parts.length, 5, '五列:项目/组别、运动员、名次、成绩、积分') + + const gap = Number(/gap:\s*(\d+)px/.exec(row)?.[1] ?? 0) + const fixed = parts.reduce((sum, part) => sum + Number(/(\d+)px/.exec(part)?.[1] ?? 0), 0) + // 1920 宽的 16:9 屏幕:覆盖层占右半边 960px,减去左右内边距 44px×2。 + const available = 960 - 44 * 2 + assert.ok(fixed + gap * (parts.length - 1) <= available, `五列 ${fixed}px + 间距会挤出右半屏`) + + const nameTrack = /minmax\(0,\s*1fr\)/.test(parts[0] ?? '') + assert.ok(nameTrack, '项目 / 组别列应可伸缩') + const athleteWidth = Number(/(\d+)px/.exec(parts[1] ?? '')?.[1] ?? 0) + assert.ok(athleteWidth >= 180, '运动员列至少 180px,单位名才不至于只剩几个字') + + const athlete = cssRule(styleText(OVERLAY_SOURCE), '.col-athlete') + assert.match(athlete, /text-overflow:\s*ellipsis/, '长姓名 / 单位名要省略号收尾') + assert.match(athlete, /white-space:\s*nowrap/) +}) + +/** + * 「这几分怎么来的、有没有被改过」——大屏不能悬停,所以: + * - 非普通成绩(破纪录加分)在项目名后面打一个来源小标签; + * - 被改过的成绩多一行整行小字 `10:42 已修订(成绩录入笔误)`。 + * + * 两处都只在需要时才渲染(v-if),没改过的行连元素都没有 —— 大屏行高与加这一行之前完全一致。 + */ +test('来源标签只在非普通成绩时渲染,普通成绩不打(行高与列宽都不受影响)', () => { + assert.match(BODY_ROW, /v-if="breakdownSourceTag\(item\)"/) + assert.match(BODY_ROW, //) + assert.equal(breakdownSourceTag(item()), '') + // 大屏标签用短词:项目名后面那一行放不下「破纪录加分」五个字。 + assert.equal(breakdownSourceTag(item({ sourceType: 'record_bonus' })), '破纪录') +}) + +test('修订小字占满一整行(grid-column: 1 / -1),不与五列抢宽度', () => { + assert.match(BODY_ROW, /v-if="breakdownRevisionLine\(item\)"/) + assert.match(BODY_ROW, /class="col-revision"/) + assert.match(BODY_ROW, /\{\{ breakdownRevisionLine\(item\) \}\}/) + const css = cssRule(styleText(OVERLAY_SOURCE), '.col-revision') + assert.match(css, /grid-column:\s*1\s*\/\s*-1/, '修订小字要横跨整行,不能挤第六列') +}) + +test('没改过的成绩不渲染小字,渲染出来的也只有一行短句', () => { + assert.equal(breakdownRevisionLine(item()), '') + const line = breakdownRevisionLine( + item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '成绩录入笔误' }), + ) + assert.doesNotMatch(line, /null|undefined|NaN|×1/) + assert.ok(line.length <= 30, `大屏一行小字要短:${line}`) +}) diff --git a/web/src/display/tests/rest-fallback-guards.test.ts b/web/src/display/tests/rest-fallback-guards.test.ts index 0bcd3ea..90a13ea 100644 --- a/web/src/display/tests/rest-fallback-guards.test.ts +++ b/web/src/display/tests/rest-fallback-guards.test.ts @@ -1,7 +1,8 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import { parseAthleteStandings, parseStandingRows } from '../guards' +import { breakdownRevisionBadge, breakdownRevisionLine } from '@/shared' +import { parseAthleteStandings, parseStandingRows, parseTeamBreakdown } from '../guards' /** * REST 兜底也要过 guards(P2 C39)。 @@ -42,8 +43,73 @@ test('单位榜 REST 数据同样过 guards', () => { assert.equal(rows[0]?.rankChange, null) }) +test('奖牌榜名次 medalRank 原样收下;旧服务端缺它时退回总榜 rank', () => { + const withField = parseStandingRows([{ teamId: 1, rank: 4, medalRank: 1, gold: 2 }]) + assert.equal(withField[0]?.medalRank, 1) + + // 旧服务端 / 老缓存里没有 medalRank:那时奖牌榜与积分榜同序,退回 rank 才是对的,不能给 0。 + const legacy = parseStandingRows([{ teamId: 1, rank: 4, gold: 2 }]) + assert.equal(legacy[0]?.medalRank, 4) +}) + test('loadRest 的两条路都走 guards,不再把裸值塞进 ref', () => { assert.match(USE_SCOREBOARD_SOURCE, /applyStandings\(parseStandingRows\(standingsResult\.value\)\)/) assert.match(USE_SCOREBOARD_SOURCE, /athletes\.value = parseAthleteStandings\(athletesResult\.value\)/) assert.doesNotMatch(USE_SCOREBOARD_SOURCE, /athletes\.value = athletesResult\.value/) }) + +/** + * 修订痕迹也要过 guards:大屏是无人值守的,服务端(尤其升级前的老版本)不下发这几个字段时 + * 必须解析成 null(= 追不出来),绝不能让 undefined 漏进模板变成「已修订 ×undefined」。 + */ +test('明细行的修订痕迹缺字段时收成 null,不显示角标', () => { + const items = parseTeamBreakdown({ + teamId: 1, + teamCode: 'A', + teamName: '第一分公司', + totalPoints: 10, + items: [{ ledgerId: 1, sourceType: 'result', eventName: '100米跑', points: 10, createdAt: '' }], + })?.items + + const row = items?.[0] + assert.equal(row?.revisionCount, null) + assert.equal(row?.lastRevisionAt, null) + assert.equal(row?.lastRevisionReason, null) + assert.equal(row?.lastRevisedBy, null) + assert.ok(row !== undefined) + assert.equal(breakdownRevisionBadge(row), '') + assert.equal(breakdownRevisionLine(row), '') +}) + +test('明细行的修订痕迹在位时原样收下并渲染成小字', () => { + const row = parseTeamBreakdown({ + teamId: 1, + teamCode: 'A', + teamName: '第一分公司', + totalPoints: 10, + items: [ + { + ledgerId: 1, + sourceType: 'result', + eventName: '100米跑', + points: 10, + createdAt: '', + revisionCount: 2, + lastRevisionAt: '2026-09-13 02:42:00', + lastRevisionReason: '成绩录入笔误', + lastRevisedBy: '张三', + }, + ], + })?.items[0] + + assert.equal(row?.revisionCount, 2) + assert.equal(breakdownRevisionBadge(row), '已修订 ×2') + assert.match(breakdownRevisionLine(row), /^(\d{2}:\d{2} )?已修订 ×2(成绩录入笔误)$/) + // 脏值(字符串 / 负数)不算「改过」,宁可不说也不能误报。 + const dirty = parseTeamBreakdown({ + teamId: 1, teamCode: 'A', teamName: 'A', totalPoints: 0, + items: [{ ledgerId: 1, points: 0, revisionCount: 'x' }], + })?.items[0] + assert.ok(dirty !== undefined) + assert.equal(breakdownRevisionBadge(dirty), '') +}) diff --git a/web/src/shared/channels.ts b/web/src/shared/channels.ts index 315c175..373b829 100644 --- a/web/src/shared/channels.ts +++ b/web/src/shared/channels.ts @@ -5,10 +5,13 @@ * 页面直接读会得到 undefined;统一在这里补默认值,读取处不必各写一份兜底。 */ -import type { AvgMode, Event, EventInput, ScoreChannel } from './types' +import type { AggregateMode, AvgMode, Event, EventInput, ScoreChannel } from './types' export const AVG_MODES: readonly AvgMode[] = ['valid', 'all'] export const DEFAULT_AVG_MODE: AvgMode = 'valid' +export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum'] +/** 聚合通道默认按成员平均汇总;缺字段的老数据 / 老服务端都按它处理。 */ +export const DEFAULT_AGGREGATE_MODE: AggregateMode = 'avg' /** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */ export const DEFAULT_COUNT_TO_TEAM = true /** 通道默认发奖牌;老服务端不下发该字段时的口径。 */ @@ -24,6 +27,16 @@ export function normalizeAvgMode(value: unknown): AvgMode { : DEFAULT_AVG_MODE } +/** + * 聚合通道的汇总口径归一化:缺字段 / 认不出的取值一律按缺省 avg 处理。 + * 只对 basis=aggregate 的通道有意义,其它通道即使带着该字段也不参与计算。 + */ +export function normalizeAggregateMode(value: unknown): AggregateMode { + return typeof value === 'string' && (AGGREGATE_MODES as readonly string[]).includes(value) + ? (value as AggregateMode) + : DEFAULT_AGGREGATE_MODE +} + export function normalizeCountToTeam(value: unknown): boolean { return typeof value === 'boolean' ? value : DEFAULT_COUNT_TO_TEAM } diff --git a/web/src/shared/index.ts b/web/src/shared/index.ts index fa06807..50fc2f6 100644 --- a/web/src/shared/index.ts +++ b/web/src/shared/index.ts @@ -9,7 +9,9 @@ export * from './types' export { ApiClient, ApiError, createApiClient } from './api' export type { ApiClientOptions, QueryParams, QueryValue } from './api' export { + AGGREGATE_MODES, AVG_MODES, + DEFAULT_AGGREGATE_MODE, DEFAULT_AVG_MODE, DEFAULT_AWARD_MEDALS, DEFAULT_COUNT_TO_MEDALS, @@ -17,6 +19,7 @@ export { DEFAULT_COUNT_TO_TEAM_MEDALS, eventInputOf, hasAggregateChannel, + normalizeAggregateMode, normalizeAvgMode, normalizeAwardMedals, normalizeCountToMedals, @@ -24,6 +27,17 @@ export { normalizeCountToTeamMedals, } from './channels' export { readTeamRanksView, readTeamRanksViews } from './teamRanks' +export { + breakdownAthleteName, + breakdownCategoryName, + breakdownRevisionBadge, + breakdownRevisionCount, + breakdownRevisionLine, + breakdownRevisionTitle, + breakdownSourceLabel, + breakdownSourceTag, + medalBoardRows, +} from './standings' export { GUEST_ROLE, isGuestRole, isGuestUser } from './session' export { resolveAssetUrl } from './asset' export { RealtimeClient } from './realtime' diff --git a/web/src/shared/rules.ts b/web/src/shared/rules.ts index 9f81a97..403d900 100644 --- a/web/src/shared/rules.ts +++ b/web/src/shared/rules.ts @@ -6,7 +6,7 @@ * 校验口径与服务端 rules.Canonical 对齐,避免前端拿到的规则包和实际生效规则不一致。 */ -import { normalizeCountToTeam } from './channels' +import { normalizeAggregateMode, normalizeCountToTeam } from './channels' import { asRecord, emptyToNull, @@ -229,6 +229,10 @@ function normalizeChannels(raw: unknown): RuleChannel[] { // 保留「未配置」与「显式开启」的区别,导出页才能如实显示规则包原文口径。 if (typeof rec.countToMedals === 'boolean') channel.countToMedals = rec.countToMedals if (typeof rec.countToTeamMedals === 'boolean') channel.countToTeamMedals = rec.countToTeamMedals + // 汇总口径同样是三态:只在规则包显式写了 avg / sum 时落到对象上(缺省 = avg,不写)。 + if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum') { + channel.aggregateMode = normalizeAggregateMode(rec.aggregateMode) + } out.push(channel) } return out diff --git a/web/src/shared/standings.ts b/web/src/shared/standings.ts new file mode 100644 index 0000000..09423a0 --- /dev/null +++ b/web/src/shared/standings.ts @@ -0,0 +1,152 @@ +import { formatClock, formatDateTime } from './format' +import type { BreakdownItem, MedalRow } from './types' + +/** + * 单位奖牌榜的服务端顺序(金 → 银 → 铜 → 单位编码)。 + * + * 排序与名次都由服务端算好(`domain.BuildMedalStandings`,REST / WS 快照 / xlsx 导出同一个函数), + * 大屏页与管理端页只按 `medalRank` 排行、按 `medalRank` 显示名次——**两边各写一套金/银/铜重排 + * 迟早会和导出件对不上**,这正是 P1-14 修过的老问题。 + * + * 旧服务端 / 老缓存不下发 `medalRank` 时退回总积分榜名次:那时奖牌榜本来就与积分榜同序, + * 显示成 0 或空才是真的错。 + */ +export function medalBoardRows(rows: readonly MedalRow[]): MedalRow[] { + return rows + .map((row) => ({ ...row, medalRank: row.medalRank > 0 ? row.medalRank : row.rank })) + .sort((a, b) => a.medalRank - b.medalRank) +} + +/** + * 单位积分明细「运动员」列的文案(管理端明细表与大屏明细覆盖层共用)。 + * + * 借用同一份的原因是:这一列的意义是回答「单位这一分是谁挣的」,两边各写一套降级 + * 迟早会变成「大屏空白格、管理端写团体」这种对不上的情况。 + * + * 团体项目 / 聚合通道(按队内平均)/ 人工调整的流水没有 athleteId,服务端下发 + * `athleteName` 是 null,这类行不能留空白格也不能打出 `null`:退回该流水的归属单位名—— + * 团体项目里单位就是拿到这一分的「谁」。连单位名都没有(理论上不会)时退到「团体」。 + * 人工调整本就没有项目与运动员,项目列已写明「人工调整」,这里给「—」不重复叨叨。 + */ +export function breakdownAthleteName( + item: Pick, + teamName: string, +): string { + const name = item.athleteName?.trim() ?? '' + if (name !== '') return name + if (item.sourceType === 'adjustment') return '—' + const team = teamName.trim() + return team === '' ? '团体' : team +} + +/** + * 单位积分明细「组别」列的文案。 + * + * 组别的来源有两处(报名项的组别、聚合流水按单位回推的组别),服务端回推不出来时是 null + * (单位在同一小项参加了多个组别,见 teamBreakdown);这里统一给占位符,不留空白格。 + */ +export function breakdownCategoryName(item: Pick): string { + const name = item.categoryName?.trim() ?? '' + return name === '' ? '—' : name +} + +/** + * 单位积分明细「来源」列的文案。 + * + * 词与 xlsx 导出侧完全一致(服务端 `export.sourceLabel`:成绩 / 破纪录加分 / 人工调整)—— + * 明细的意义就是「屏幕看到的和导出的对得上」,两边各写一套措辞迟早被当成两份数据。 + * 取不到 / 认不出的来源按「成绩」显示:老服务端不下发时它本来就是成绩流水。 + */ +export function breakdownSourceLabel(item: Pick): string { + switch (item.sourceType) { + case 'adjustment': + return '人工调整' + case 'record_bonus': + return '破纪录加分' + default: + return '成绩' + } +} + +/** + * 大屏明细覆盖层要不要给这一行打「来源」标签:返回空串表示不打。 + * + * 大屏只有 960px 的半屏,穷得放不下第六列: + * - 普通成绩(result)是绝大多数,打了只是给每一行加噪声,直接省略; + * - 人工调整流水没有项目名,项目列本来就写着「人工调整」,再打一个标签是同义重复; + * - 破纪录加分是站在成绩旁边的额外分(项目列写的是小项名),必须打出来才看得懂这几分哪来的。 + */ +export function breakdownSourceTag(item: Pick): string { + if (item.sourceType === 'result') return '' + if ((item.eventName ?? '').trim() === '') return '' + // 大屏用短词「破纪录」:它要挤在项目名后面一行小字里,五个字会把项目名挤没; + // 管理端明细与 xlsx 导出仍是完整的「破纪录加分」。 + if (item.sourceType === 'record_bonus') return '破纪录' + return breakdownSourceLabel(item) +} + +/** 修订次数:追不出来(null / 缺字段)与没改过(0)都是 0,只有 > 0 才谈得上「已修订」。 */ +export function breakdownRevisionCount(item: Pick): number { + const count = item.revisionCount + if (typeof count !== 'number' || !Number.isFinite(count) || count <= 0) return 0 + return Math.floor(count) +} + +/** 管理端明细行尾的淡色角标文案;没改过返回空串(不占位、不留空角标)。 */ +export function breakdownRevisionBadge(item: Pick): string { + const count = breakdownRevisionCount(item) + return count === 0 ? '' : `已修订 ×${count}` +} + +/** + * 管理端角标的悬停文案:`2026-09-13 10:42 · 已修订 ×2(成绩录入笔误)· 张三`。 + * + * 屏幕放不下的一律塞进悬停里:完整时间、完整原因、操作人。缺哪段就少哪段, + * 一点都不剩(没改过)时返回空串,调用方连 title 都不用挂。 + */ +export function breakdownRevisionTitle( + item: Pick, +): string { + const count = breakdownRevisionCount(item) + if (count === 0) return '' + const parts: string[] = [] + // formatDateTime 解析不出来时给的是占位符「—」,那不是时间,别挂到悬停里。 + const at = formatDateTime(item.lastRevisionAt ?? null) + if (at !== '—') parts.push(at) + const reason = (item.lastRevisionReason ?? '').trim() + parts.push(reason === '' ? `已修订 ×${count}` : `已修订 ×${count}(${reason})`) + const by = (item.lastRevisedBy ?? '').trim() + if (by !== '') parts.push(by) + return parts.join(' · ') +} + +/** 大屏小字里原因最多留几个字:一行小字放不下长理由,超了截断,完整理由在管理端悬停里。 */ +const REVISION_REASON_MAX = 14 + +/** + * 大屏明细覆盖层的一行小字:`10:42 已修订(成绩录入笔误)`;没改过返回空串。 + * + * 大屏不能悬停,还坐着离屏幕十几米的观众,所以这一行必须短、必须一眼看懂: + * 时间用 HH:mm(当天哪一刻改的),次数只在改过不止一次时写(一次是常态,×1 是噪声), + * 原因截断到十几个字。宁可少写一个字,也不能让观众看到看不懂的术语。 + */ +export function breakdownRevisionLine( + item: Pick, +): string { + const count = breakdownRevisionCount(item) + if (count === 0) return '' + const reason = (item.lastRevisionReason ?? '').trim() + const bracketed = + reason === '' + ? '' + : `(${reason.length > REVISION_REASON_MAX ? `${reason.slice(0, REVISION_REASON_MAX)}…` : reason})` + const mark = count > 1 ? `已修订 ×${count}` : '已修订' + const at = revisionClock(item.lastRevisionAt ?? null) + return at === '' ? `${mark}${bracketed}` : `${at} ${mark}${bracketed}` +} + +/** `HH:mm`(本地时区);解析不出来返回空串——宁可不写时间,也不写「--:--」。 */ +function revisionClock(iso: string | null): string { + const full = formatClock(iso) + return full.startsWith('--') ? '' : full.slice(0, 5) +} diff --git a/web/src/shared/tests/standings.test.ts b/web/src/shared/tests/standings.test.ts new file mode 100644 index 0000000..1375f32 --- /dev/null +++ b/web/src/shared/tests/standings.test.ts @@ -0,0 +1,222 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + breakdownAthleteName, + breakdownCategoryName, + breakdownRevisionBadge, + breakdownRevisionCount, + breakdownRevisionLine, + breakdownRevisionTitle, + breakdownSourceLabel, + breakdownSourceTag, + medalBoardRows, +} from '../standings' +import type { BreakdownItem, MedalRow } from '../types' + +/** + * 单位奖牌榜的顺序与名次由服务端算好(金 → 银 → 铜 → 单位编码,见 domain.BuildMedalStandings), + * 前端只按 medalRank 应用。这一层要钉住的是: + * 1. 名次用 medalRank,不拿总积分榜的 rank(否则"金牌多的单位排后面"会在界面上复现); + * 2. 奖牌榜顺序与积分榜顺序确实是两套(金牌多但总分低的单位排前面); + * 3. 旧服务端不下发 medalRank 时退回 rank,不显示成 0。 + */ + +function row(over: Partial & Pick): MedalRow { + return { + teamCode: `T${over.teamId}`, + teamName: `第${over.teamId}队`, + shortName: null, + color: null, + logoPath: null, + rank: 0, + medalRank: 0, + gold: 0, + silver: 0, + bronze: 0, + medalTotal: 0, + ...over, + } +} + +test('奖牌榜按 medalRank 排行:金牌多但总分低的单位排在前面', () => { + // 服务端口径:A 金牌 1 枚(总榜第 2),B 一块牌没有(总榜第 1)。 + const rows = [row({ teamId: 1, medalRank: 2, rank: 2, gold: 1 }), row({ teamId: 2, medalRank: 1, rank: 1 })] + + const board = medalBoardRows(rows) + + assert.deepEqual(board.map((item) => item.teamId), [2, 1]) + assert.deepEqual(board.map((item) => item.medalRank), [1, 2]) + // 传入的数组不被就地改动(积分榜那块还在用它)。 + assert.deepEqual(rows.map((item) => item.teamId), [1, 2]) +}) + +test('名次取 medalRank,不拿总积分榜的 rank 顶替', () => { + const board = medalBoardRows([row({ teamId: 7, rank: 5, medalRank: 3, gold: 1 })]) + + assert.equal(board[0]?.medalRank, 3) + assert.equal(board[0]?.rank, 5, '总积分榜名次原样保留,两套名次并存') +}) + +test('旧服务端 / 老缓存没有 medalRank 时退回 rank,不显示成 0', () => { + const board = medalBoardRows([row({ teamId: 1, rank: 2 }), row({ teamId: 2, rank: 1 })]) + + assert.deepEqual(board.map((item) => item.medalRank), [1, 2]) +}) + +/** + * 单位积分明细的「运动员」列:现场要能一眼看出这一分是谁挣的, + * 团体项目(服务端不下发 athleteName)必须退回单位名而不是空白格或 null。 + */ +function item(over: Partial = {}): BreakdownItem { + return { + ledgerId: 1, + sourceType: 'result', + eventId: 1, + eventCode: 'M100', + eventName: '100米跑', + categoryName: '男子', + athleteId: 7, + athleteName: '张伟', + rank: 1, + resultText: '11.58', + points: 10, + createdAt: '2026-09-13 10:00:00', + ...over, + } +} + +test('明细行有运动员时显示运动员名', () => { + assert.equal(breakdownAthleteName(item(), '第一分公司'), '张伟') +}) + +test('团体项目(athleteName 为空 / null / 只有空白)退回单位名,不留空白格也不显示 null', () => { + for (const athleteName of [null, '', ' ']) { + const cell = breakdownAthleteName(item({ athleteName, athleteId: null }), '第一分公司') + assert.equal(cell, '第一分公司') + assert.doesNotMatch(cell, /null|undefined/) + } +}) + +test('单位名也拿不到时退到固定占位,不留空白格', () => { + assert.equal(breakdownAthleteName(item({ athleteName: null }), ' '), '团体') +}) + +test('人工调整没有运动员,项目列已写明人工调整,这里不重复', () => { + const cell = breakdownAthleteName( + item({ sourceType: 'adjustment', athleteName: null, athleteId: null, eventId: null, eventName: null }), + '第一分公司', + ) + assert.equal(cell, '—') +}) + +test('运动员名去掉首尾空白,不把空格当成「有名字」', () => { + assert.equal(breakdownAthleteName(item({ athleteName: ' 张伟 ' }), '第一分公司'), '张伟') +}) + +test('明细行「组别」列拿不到组别时给占位符(聚合流水跨组别回推不出来)', () => { + assert.equal(breakdownCategoryName(item()), '男子') + assert.equal(breakdownCategoryName(item({ categoryName: null })), '—') + assert.equal(breakdownCategoryName(item({ categoryName: ' ' })), '—') +}) + +/** + * 明细行的「来源」:屏幕与 xlsx 导出必须是同一套词(成绩 / 破纪录加分 / 人工调整), + * 否则同一场比赛的同一分在两处叫两个名字,对账的人不知道该信哪份。 + */ +test('来源文案与导出侧 sourceLabel 同一套词', () => { + assert.equal(breakdownSourceLabel(item({ sourceType: 'result' })), '成绩') + assert.equal(breakdownSourceLabel(item({ sourceType: 'record_bonus' })), '破纪录加分') + assert.equal(breakdownSourceLabel(item({ sourceType: 'adjustment' })), '人工调整') +}) + +test('老服务端下发认不出的来源时按成绩显示,不显示成英文枚举', () => { + const label = breakdownSourceLabel(item({ sourceType: 'whatever' as BreakdownItem['sourceType'] })) + assert.equal(label, '成绩') +}) + +test('大屏只给「破纪录加分」这类非普通成绩打来源标签', () => { + assert.equal(breakdownSourceTag(item()), '', '普通成绩不打标签:一行行都写「成绩」只是噪声') + // 大屏那一行要短:用短词「破纪录」,管理端与导出仍是完整的「破纪录加分」。 + assert.equal(breakdownSourceTag(item({ sourceType: 'record_bonus' })), '破纪录') + assert.equal(breakdownSourceLabel(item({ sourceType: 'record_bonus' })), '破纪录加分') + // 人工调整流水没有项目名,项目列本来就写着「人工调整」,不再重复打标签。 + assert.equal(breakdownSourceTag(item({ sourceType: 'adjustment', eventName: null })), '') + assert.equal(breakdownSourceTag(item({ sourceType: 'adjustment', eventName: ' ' })), '') +}) + +/** + * 修订痕迹:「被改过」是现场裁判长最想知道的(分错了又改了一遍吗),也是最不能误报的。 + */ +test('没改过(0)与追不出来(null / 缺字段)都不显示角标,只有 > 0 才算已修订', () => { + for (const revisionCount of [0, null, undefined, -1, Number.NaN]) { + const row = item({ revisionCount }) + assert.equal(breakdownRevisionCount(row), 0, `revisionCount=${String(revisionCount)} 不该算已修订`) + assert.equal(breakdownRevisionBadge(row), '') + assert.equal(breakdownRevisionLine(row), '') + assert.equal(breakdownRevisionTitle(row), '') + } + assert.equal(breakdownRevisionBadge(item({ revisionCount: 2 })), '已修订 ×2') + assert.equal(breakdownRevisionBadge(item({ revisionCount: 1 })), '已修订 ×1') +}) + +test('大屏小字:时间 + 已修订(原因),改过不止一次才写次数', () => { + const one = item({ + revisionCount: 1, + lastRevisionAt: '2026-09-13 10:42:00', + lastRevisionReason: '成绩录入笔误', + }) + // 库里存的是 UTC(SQLite datetime('now') 不带时区),按本地时区展示。 + const clock = new Date('2026-09-13T10:42:00Z') + const hhmm = `${String(clock.getHours()).padStart(2, '0')}:${String(clock.getMinutes()).padStart(2, '0')}` + assert.equal(breakdownRevisionLine(one), `${hhmm} 已修订(成绩录入笔误)`) + assert.doesNotMatch(breakdownRevisionLine(one), /×1|null|undefined/) + + const twice = item({ revisionCount: 2, lastRevisionAt: '2026-09-13 10:42:00' }) + assert.equal(breakdownRevisionLine(twice), `${hhmm} 已修订 ×2`) +}) + +test('大屏小字:原因太长就截断,没写原因就只显示时间与「已修订」', () => { + const long = breakdownRevisionLine( + item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '裁判在后台把成绩点错了又重新改了一遍' }), + ) + assert.match(long, /…)$/) + assert.ok(long.length <= 30, `一行小字要短,得到 ${long.length} 字:${long}`) + + const noReason = breakdownRevisionLine(item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00' })) + assert.doesNotMatch(noReason, /(|null|undefined/) +}) + +test('大屏小字:时间解析不出来时不写「--:--」,也不留前导空格', () => { + const line = breakdownRevisionLine(item({ revisionCount: 1, lastRevisionAt: null, lastRevisionReason: '笔误' })) + assert.equal(line, '已修订(笔误)') + assert.equal(breakdownRevisionLine(item({ revisionCount: 1, lastRevisionAt: '不是时间' })), '已修订') +}) + +test('管理端悬停:完整时间 + 次数 + 原因 + 操作人,缺哪段就少哪段', () => { + // 悬停里是完整时间(本地时区),与大屏小字同一份 UTC 串。 + const date = new Date('2026-09-13T10:42:00Z') + const stamp = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + assert.equal( + breakdownRevisionTitle( + item({ + revisionCount: 2, + lastRevisionAt: '2026-09-13 10:42:00', + lastRevisionReason: '成绩录入笔误', + lastRevisedBy: '张三', + }), + ), + `${stamp} · 已修订 ×2(成绩录入笔误) · 张三`, + ) + // 现场没写原因、操作人已删号:只留时间与次数,不编也不留空括号。 + assert.equal( + breakdownRevisionTitle(item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00' })), + `${stamp} · 已修订 ×1`, + ) + // 时间也解析不出来时,至少还有次数。 + assert.equal( + breakdownRevisionTitle(item({ revisionCount: 3, lastRevisionAt: 'x', lastRevisedBy: ' ' })), + '已修订 ×3', + ) +}) diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index d92f8bd..21af6ec 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -83,6 +83,13 @@ export type ChannelBasis = 'result' | 'manual' | 'aggregate' */ export type AvgMode = 'valid' | 'all' +/** + * 聚合通道(basis=aggregate)的汇总口径: + * `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名。 + * 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」。 + */ +export type AggregateMode = 'avg' | 'sum' + export interface ScoreChannel { id: number eventId: number @@ -90,6 +97,11 @@ export interface ScoreChannel { basis: ChannelBasis profileId: number sortOrder: number + /** + * basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和。 + * 缺字段 = avg:老服务端不下发它(缺省值不下发),归一化见 normalizeAggregateMode。 + */ + aggregateMode?: AggregateMode /** false 表示该通道产生的分只记在个人头上,不进单位总分。 */ countToTeam: boolean /** @@ -399,7 +411,15 @@ export interface TeamRef { } export interface MedalRow extends TeamRef { + /** 总积分榜名次(总分优先)。奖牌榜的名次是另一套,见 medalRank。 */ rank: number + /** + * 单位奖牌榜的名次:金 → 银 → 铜 → 单位编码,与 totalPoints 无关。 + * + * 服务端按这套口径算好下发(`domain.BuildMedalStandings`,REST / WS 快照 / xlsx 同源); + * 前端只按它排序与显示,**不自己按金/银/铜重排**,否则名次会与导出件打架。 + */ + medalRank: number gold: number silver: number bronze: number @@ -446,6 +466,20 @@ export interface BreakdownItem { resultText: string | null points: number createdAt: string + /** + * 这条成绩发布之后被改过几次(口径见服务端 `internal/revisions`)。 + * + * `null` = 追不出来(聚合流水、人工调整、没有版本基线),`0` = 追出来了但没改过。 + * 只有 > 0 才显示「已修订」角标 —— 宁可不说,也不能把没改过的成绩标成改过。 + * 老服务端不下发这几个字段,所以都按可空解析(null)。 + */ + revisionCount?: number | null + /** 最后一次改动的时间(SQLite UTC 串,展示时按本地时区格式化)。 */ + lastRevisionAt?: string | null + /** 最后一次改动的原因;现场没写就是 null,界面上不编。 */ + lastRevisionReason?: string | null + /** 最后一次改动的操作人显示名;查不到(已删号 / 老服务端)就是 null。 */ + lastRevisedBy?: string | null } export interface TeamBreakdown { @@ -844,6 +878,8 @@ export interface RuleChannel { scope: ChannelScope basis: ChannelBasis profile: string + /** basis=aggregate 通道的汇总口径;缺省视为 avg(规则包不必写缺省值)。 */ + aggregateMode?: AggregateMode /** 缺省视为 true;「个人+团体」模板的个人通道会显式写 false。 */ countToTeam?: boolean /** 缺省视为 true(该通道发奖牌);规则包显式写 false 才不发。 */