feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复

## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
  按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
  operations/     放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
  interceptions/  拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
                  DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
                  否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
  降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
  防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。

## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
  示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
  本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。

修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。

测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
This commit is contained in:
AskaEth committed 2026-09-11 01:59:58 +08:00
1 parent 8e707e1e9b
commit e5e0944d26
32 files changed
+2016 -137

No files matched your search

+2
View File
@@ -22,6 +22,8 @@
- **参赛单位名册**:启用 / 停用 / **彻底删除**三档——「停用」保留全部历史数据、随时可启用;「彻底删除」是**假删除**,只把单位从列表里移除(界面上没有恢复入口),它的报名、成绩、积分流水与名下运动员全部保留,**积分榜不会变化**;报名表里再次用到该单位编码会让它自动恢复
- **赛事独立存档**:每个赛事一个独立项目目录,整包可导出/导入(含会标、背景图、单位 logo 与上传的字体),并可在管理端页内一键切换当前赛事
- **结果导出**:Excel 总表、全部(多 sheet)、**全部(单页)**、分项成绩表、单位积分明细可分别导出;「积分榜」页三个榜单的卡片头各有自己的明细导出——单位总分榜**导出总明细**(总表 + 每个有数据的单位一张分表)、团体奖牌榜**导出奖牌明细**(逐枚奖牌列出来源)、个人积分榜**导出个人明细**(逐条得分来源,与个人榜一致且不受折算系数影响),展开单位还能**导出该单位明细**
- **日志可复盘**:服务端在数据目录 `logs\` 下按「放行 / 拦截」两类分目录落盘——`operations\` 记放行的写操作与常规运行日志,`interceptions\` 记我们主动拦下的报错与服务端异常;拦截日志带中文原因与**拦截点 `文件:行 函数名`**、操作人与 requestId,两类都按天 + 按大小轮转并自动清理旧文件(见部署指南第 10 节)
- **成绩写法容错**:时间类接受 `66`、`66.33`、`1:06`、`1:06.33`、`1分06秒`、`1小时2分3秒` 等写法(全角冒号 / 全角数字也认),秒位 ≥ 60 会被拒但报错写明原因;成绩格里的示例是带「例」字的淡色斜体灰字,不会和已填内容混淆
- **单位 logo**:积分榜、奖牌榜、分项成绩表与个人榜都会显示参赛单位 logo
- **大屏字体可配**:标题字体与正文字体可在「系统设置 → 赛事信息 → 字体」里从四款内置字体中选,或上传自己的字体(ttf / otf / woff / woff2,单个不超过 20 MiB);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入
+8 -1
View File
@@ -14,4 +14,11 @@ backup:
keep: 20 # 每个库滚动保留的份数,超出的旧备份自动删除
log:
level: info # debug | info | warn | error
level: info # debug | info | warn | error(作用于控制台与 operations 放行类日志)
# 日志根目录,其下分两类:
# operations/ 放行的操作 + 常规运行日志(按上面的 level 过滤)
# interceptions/ 我们主动拦截的报错 + 服务端异常(固定按 debug 采集,便于复盘"为什么被拦/在哪一行拦的")
# 留空 = <数据目录>/logs
dir: ""
maxSizeMB: 20 # 单个日志文件大小上限(MB);<=0 用内置默认
keepFiles: 10 # 每类保留的份数(含当前文件);<=0 用内置默认
+1
View File
@@ -57,6 +57,7 @@
## 6. 数据与备份
- [ ] 确认数据目录 `%LOCALAPPDATA%\ScoreboardConsole\data` 下的 `backups\` 里已出现 `system-auto-*.db` 与 `event-auto-*.db`(默认每 5 分钟)
- [ ] 确认数据目录下已生成 `logs\operations\` 与 `logs\interceptions\`(服务端日志,见部署指南第 10 节),并知道操作被拒时去 `interceptions\` 当天文件里搜原因与拦截点
- [ ] 开幕前手动点一次「立即备份」(「系统设置 → 备份 → 立即备份」)
- [ ] 把整个数据目录(不只是 `backups\`:`event_projs\<当前项目名>\uploads\` 里还有会标、背景图、单位 logo 与上传的字体)拷一份到 U 盘
- [ ] 留一份成绩总表存档:到「导出 → 结果导出」导一份**全部(单页)**(`赛事成绩总表(单页).xlsx`,一个工作簿一张表,适合比对与筛选),需要归档打印再导一份「全部」(多 sheet)
+18 -3
View File
@@ -76,11 +76,19 @@
| 列 | 操作 |
| --- | --- |
| 成绩输入 | 直接输入成绩。时间类项目按 `1:06.33` 格式;工具栏会提示当前单位(秒 / 米 / 次 / 分) |
| 成绩输入 | 直接输入成绩;工具栏会提示当前单位(秒 / 米 / 次 / 分),成绩格比过去更宽,四位数也看得全 |
| 名次 | 自动排名项目由系统自动计算,不可编辑;手动名次项目(录入方式为「手动名次」或比较方式为「不比较」)可直接填名次 |
| 状态 | 下拉选择:**有效**、**DNS 弃赛**、**DQ 取消**、**DNF 未完赛**、**退赛** |
| 积分(自动) | 随成绩实时计算,仅供参考 |
| 备注 | 自由填写 |
| 备注 | 自由填写,示例文本是 `可留空` |
**成绩写法**:能接受的写法比过去多,中文输入法下的全角冒号 `1:06`、全角数字也认。
- 时间类(单位「秒」):`66`、`66.33`、`1:06`、`1:06.33`、`1:6`、`1分06秒`、`1分6秒`、`1分06`、`1小时2分3秒`;
- 距离 / 计数 / 得分类:按工具栏提示的单位填数字,如米 `12.34`、次 `12`、分 `96.5`;
- 秒位 ≥ 60 仍然拒绝(`1:70` 不是 70 秒),但报错会写明原因(见 4.4)。
**示例文本不是已填内容**:空格子里显示的 `例 1:06.33`(距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`)是**更淡的斜体灰字**,只是告诉你大概怎么写,**不代表这一格已经有成绩**。灰字不会被提交;点发布时如果那一格还是灰字,就按「成绩为空」拦下。过去示例文本长得和真实输入一样,容易看成「我已经填了」。
右侧「实时积分预览」显示:若现在发布,各单位积分的变化、最大分差、受影响单位数。
@@ -104,7 +112,11 @@
页面下方「防呆校验」会实时列出问题:
- **错误(阻止发布)**:缺少成绩、成绩格式无法解析、手动名次项目缺少名次;
- **错误(阻止发布)**:成绩格有三种原因,文案都会**把那一格的实际内容原样打出来**,并对症给出改法,不会只说一句「缺少成绩」让你去改本来就对的格式:
- 空格子:`第 3 行 张三:成绩为空(当前「(空)」),请填写成绩`;
- 秒位 ≥ 60:`第 3 行 张三:秒位不能 ≥ 60(当前「1:70」),请写成 1:06 或 66 这类`;
- 写法无法识别:`第 3 行 张三:成绩写法无法识别(当前「约1分06秒」),可用 66、66.33、1:06、1:06.33、1分06秒`——非时间单位会换成对应的数字提示(不会拿时间格式去教距离项目);
- 另外还有:手动名次项目缺少名次。
- **提醒(不阻止发布)**:成绩 ≤ 0、名次重复(请确认是否并列)、成绩与赛会纪录差距过大。
有错误时点「发布」会被拦下并提示第一处问题。
@@ -112,6 +124,7 @@
### 4.5 存草稿与发布
1. **存草稿**:保存当前组别的成绩,草稿**不会上屏**,也不计入积分榜;有未保存修改时页面会显示「有未保存修改」徽标。
- **未保存的成绩不会被静默丢掉**:有未保存修改时,点左侧菜单切到别的页面(以及浏览器前进 / 后退)会先弹出确认框,确认丢弃才离开;关闭或刷新浏览器时浏览器也会原生提示;切换项目本来就会先确认。选「取消」留在本页,改动都还在。
2. **发布**:点击后弹出确认框,显示本次影响的单位数与最大分差;确认后成绩立即上屏并重算积分榜。
- 发布前系统会自动把当前内容整组保存为草稿再发布,无需手动先存;
- 示例数据模式下「发布」按钮不可用,避免把未落库的改动当成发布成功。
@@ -530,6 +543,8 @@
4. 筛选与分页作用于最近 200 条记录;
5. 该页面仅管理员可查看。
> 本页是**业务审计**(谁在什么时候改了什么),只记**放行**的操作。想看「某次操作为什么被系统拦下」——比如保存被拒、导入报错、切页提示离线——那要看服务端写在数据目录 `logs\` 下的**两类日志文件**(放行类 / 拦截类):我们主动拦下的报错会写明中文原因、拦截点 `文件:行 函数名`、操作人与 requestId。位置、轮转与复盘步骤见[部署指南 · 服务端日志与拦截复盘](./部署指南.md#10-服务端日志与拦截复盘)。
---
## 14. 系统设置
+37 -2
View File
@@ -37,7 +37,7 @@
撤销发布 → 修改 → 重新发布。撤销会作废该组积分流水,大屏回退到发布前状态,过程可在「变更历史」查看。
**Q9:为什么「发布」是灰的?**
常见原因:未登录、服务端断开、示例数据模式,或存在阻止发布的校验错误(缺少成绩、格式无法解析、手动名次项目缺名次)。
常见原因:未登录、服务端断开、示例数据模式,或存在阻止发布的校验错误(成绩为空、秒位 ≥ 60、成绩写法无法识别、手动名次项目缺名次)。成绩格的报错现在会把**那一格的实际内容**打出来并对症给改法,照着提示改即可(见 Q75、Q76)。
**Q10:并列名次怎么处理?**
成绩相同(或手动名次相同)自动按并列处理,显示同一名次。默认口径是「顺延」:并列第 3 各得第 3 名的分,无第 4 名,下一名第 5 名;奖牌同样并列(并列第 1 各记 1 金、无银)。具体口径由规则包配置决定。
@@ -47,7 +47,7 @@
唯一例外:「个人+团体」小项若把平均值口径选成「全部报名人员都计入」,这些人在队内平均里会按 0 分占一个分母名额(见 Q15)。
**Q12:时间类成绩怎么写?**
按 `分:秒.毫秒` 形式输入,例如 `1:06.33`;工具栏会显示当前项目的单位。
按 `分:秒.毫秒` 形式输入最直观,例如 `1:06.33`;工具栏会显示当前项目的单位。实际上能接受的写法比过去多,中文输入法下的全角冒号 `1:06`、全角数字也认,完整清单见 Q76。成绩格里的 `例 1:06.33` 是**更淡的斜体灰字**,只是示例、不是已填内容(见 Q75)。
**Q13:能批量录入吗?**
可以。支持从 Excel 复制后粘贴:两列及以上时第一列按单位名称 / 简称匹配行、最后一列作为成绩;单列时从当前行向下依次填充。也可以用 `Tab` / `Enter` 在格子间移动,`Ctrl+Enter` 存草稿。
@@ -407,3 +407,38 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
**Q72:个人积分明细导出的数字跟个人积分榜对得上吗?会不会受榜单口径系数影响?**
对得上,而且**不受系数影响**。「导出个人明细」(`个人积分总明细.xlsx`)里的 `得分` 是**记到个人的原始分**,与「个人积分榜」逐个数字一致;「基础数据 → 积分模板 → 榜单口径」里的个人项目得分折算系数(键名 `standings.individual_to_team_weight`,默认 1)只在汇总环节把个人分折算进单位总分,**不会改变个人明细里的数字**——比如把系数改成 0.5,个人明细的得分仍是原始分,只有单位总分变小(见 Q18)。
**Q73:操作被拒绝了,怎么查是谁在什么时候、因为什么被拦的?**
服务端把「主动拦截」单独记到数据目录的拦截日志里(默认 `%LOCALAPPDATA%\ScoreboardConsole\data\logs\interceptions\`):
1. 找当天的 `interceptions-YYYYMMDD.log`(同一天有续写文件时按 `.1`、`.2` 往后看);
2. 搜 `请求被拦截`(WARN)那一行,它已经带齐:`reason` 中文原因、`origin` **拦截点**(形如 `internal/httpapi/repo_events.go:231 normalizeAvgMode`,直接看出是哪个函数的哪一行拦下的)、`code` / `method` / `path`,以及 `actor`(`用户名#id`,未登录是 `guest`)/ `role` / `requestId`;
3. 想定位得更准:浏览器开发者工具 **Network** 面板点开那条失败请求,复制响应头 `X-Request-Id`,拿它去日志文件里搜;同值的 DEBUG 行「拦截上下文」还会补 `detail` 与来源 IP;
4. 想对照同一时间有没有成功的操作,去 `operations\` 里搜同一个 `requestId`(放行日志带 action / 实体 / 操作人)。
中间件层的拦截(未登录 401、无权限 403、CSRF 校验失败、未改初始密码、操作过于频繁 429)同样记在这里。完整说明见部署指南第 10 节。
**Q74:日志文件在哪?会不会把盘写满?**
服务端日志在**数据目录**下的 `logs\`,分两类:
- `operations\`(放行类):放行的写操作(`操作已放行`,含 action / 实体 / 操作人 / requestId)+ 启动、数据库迁移等常规运行日志,按 `log.level` 过滤(默认 `info`);
- `interceptions\`(拦截类):我们主动拦下的报错 + 服务端异常(5xx),**固定按 debug 采集,不受 `log.level` 影响**。
不会写满:文件名按天(`interceptions-20260911.log`),同一天写满一个文件后续写 `.1`、`.2`…;每类保留份数**含当前文件**,超出自动删最旧的。单文件上限与保留份数由 `config.yaml` 的 `log.maxSizeMB`(默认 20 MB)、`log.keepFiles`(默认 10)决定,两类合计最多约 `2 × keepFiles × maxSizeMB`(默认约 400 MB);目录可用 `log.dir` 改(留空 = `<数据目录>\logs`)。日志目录不可写时,服务端只告警一次并降级为只写控制台,不影响业务。
别和 Console 自己的 `logs\console.log`(在安装目录里)混淆:那个装的是 Console 与服务端的控制台输出;服务端日志文件在数据目录。
**Q75:为什么我明明填了成绩,发布却说缺少成绩?**
最可能是把格子里的**灰字示例文本**当成了已填内容。过去成绩格的示例是 `1:06.33`,没有样式区分,看起来就像已经填好;现在示例统一带「例」字(时间 `例 1:06.33`、距离 `例 12.34`、计数 `例 12`、得分 `例 96.5`,备注格是 `可留空`),并且是**更淡的斜体灰字**,和真实输入明显不一样。灰字不会被提交,那一格仍然是空的。
新版报错也会自证:会写 `第 3 行 张三:成绩为空(当前「(空)」),请填写成绩`,把那一格的实际内容原样打出来。填了但写法不认的是另外两条:`秒位不能 ≥ 60(当前「1:70」)…` 或 `成绩写法无法识别(当前「约1分06秒」)…`,各自带内容与可用写法(见 Q76)。
**Q76:成绩能怎么填?**
按项目单位填(工具栏会显示当前单位:秒 / 米 / 次 / 分):
- **时间类(秒)**:`66`、`66.33`、`1:06`、`1:06.33`、`1:6`、`1分06秒`、`1分6秒`、`1分06`、`1小时2分3秒` 都认;中文输入法下的**全角冒号 `1:06` 与全角数字**也认;
- **距离 / 计数 / 得分类**:按提示填数字,例如米 `12.34`、次 `12`、分 `96.5`;无法识别时的提示会换成对应的数字说明,不会拿时间格式去教距离项目;
- **秒位 ≥ 60 会被拒**(`1:70` 不是 70 秒),但原因写明:`秒位不能 ≥ 60(当前「1:70」),请写成 1:06 或 66 这类`;
- 成绩格已经加宽,四位数也能看全。
有未保存的成绩时,切到别的菜单页面会先弹确认框,不会静默丢弃。
+70 -7
View File
@@ -13,7 +13,7 @@
| 网络 | 完全离线即可运行。若需要手机/平板/电视访问,用独立交换机或路由器组成局域网 |
| 磁盘 | 建议预留 ≥ 10GB |
| 运行时 | 无需安装 Go / Node / 数据库等任何运行时,前端已内嵌进可执行文件 |
| 权限 | 首次运行会在 `%LOCALAPPDATA%\ScoreboardConsole\` 下建数据目录并写入配置/规则(见 3.5),在部署目录的 `logs\` 写日志,请确保这两处可写 |
| 权限 | 首次运行会在 `%LOCALAPPDATA%\ScoreboardConsole\` 下建数据目录并写入配置/规则(见 3.5),并在其下 `data\logs\` 写服务端日志(见第 10 节);Console 自己还在部署目录的 `logs\` 写 `console.log`,请确保这两处可写 |
> 服务端只构建 Windows amd64 一个版本。Linux / 其他平台不在交付范围内。
@@ -53,7 +53,7 @@ deploy\build.bat
3. 安装完成后,桌面会出现名为**「赛事积分榜」**的快捷方式。
4. 双击「赛事积分榜」启动。
> 赛事数据、配置与规则都存放在 `%LOCALAPPDATA%\ScoreboardConsole\`(不随安装目录走,见 3.4、3.5),日志写在安装目录的 `logs\` 下;安装目录本身只要当前用户可写即可。
> 赛事数据、配置与规则都存放在 `%LOCALAPPDATA%\ScoreboardConsole\`(不随安装目录走,见 3.4、3.5);服务端日志在数据目录的 `data\logs\` 下,Console 自己的 `console.log` 在安装目录的 `logs\` 下(见第 10 节);安装目录本身只要当前用户可写即可。
### 3.2 使用免安装包
@@ -91,12 +91,14 @@ deploy\build.bat
```
%LOCALAPPDATA%\ScoreboardConsole\ # 用户数据目录(与安装目录无关)
├── data\ # 数据库、备份、上传的图片
├── data\ # 数据库、备份、上传的图片、服务端日志(data\logs\)
├── console\config.json # Console 自己的配置(实际生效的那份)
├── config.yaml # 服务端配置(实际生效的那份)
└── rules\ # 规则包(实际生效的那份)
```
> 服务端日志默认落在 `data\logs\` 下(`operations\` 放行类、`interceptions\` 拦截类,见第 10 节),所以它同样在安装目录之外、覆盖安装不会丢。
### 3.4 数据放在哪里(升级/卸载都不会删)
覆盖安装时安装程序会**先静默运行旧版卸载器**,而卸载器会把整个安装目录删掉,所以数据库、规则、用户、上传的图片与字体都不能放在安装目录里。现在的规则:
@@ -108,6 +110,7 @@ deploy\build.bat
| 直接双击 `scoreboard.exe`(不经 Console) | 服务端默认行为不变:用工作目录下的 `data\`,**这种用法在覆盖安装时仍会丢数据** |
- Console 启动时会把这个目录用 `-data` 参数交给服务端,界面上方「运行日志」里能看到 `数据目录:...` 这一行;
- **服务端日志也在数据目录下**:`<数据目录>\logs\` 里分 `operations\`(放行类)与 `interceptions\`(拦截类)两个子目录,按天 + 按大小轮转、自动清理旧文件,可用 `config.yaml` 的 `log:` 段改位置与保留份数(见第 10 节)。它在安装目录之外,覆盖安装不会丢;
- **上传资源随赛事项目走**:赛事会标、大屏背景图、单位 logo,以及「赛事信息 → 字体」里上传的**字体文件**,都在 `<数据目录>\event_projs\<项目名>\uploads\` 里(字体在 `uploads\fonts\`)。它们是文件而不是数据库记录,**不在 `backups\` 的 `.db` 快照里**,备份 / 迁移时要连这个目录一起拷(见 8.5、8.6);
- **一次性迁移**:如果新数据目录还是空的,而旧的 `<安装目录>\data` 里有数据,Console 会在启动时把旧目录**整体改名**过去(同盘很快),日志里会写「已把旧数据从 X 迁移到 Y」。任何失败(跨盘、权限不足等)都只记日志,旧数据原地保留,不会被删;
- 如果两个目录里**都有数据**,Console 不做任何动作,只写一条警告日志,由现场人工决定保留哪一份;
@@ -123,7 +126,7 @@ deploy\build.bat
| 内容 | 实际生效的位置 | 管什么 |
| --- | --- | --- |
| Console 配置 | `%LOCALAPPDATA%\ScoreboardConsole\console\config.json` | 端口、选屏策略、开机自启等(见 6.2) |
| 服务端配置 | `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` | 监听地址/端口、备份间隔、日志级别(见 6.1) |
| 服务端配置 | `%LOCALAPPDATA%\ScoreboardConsole\config.yaml` | 监听地址/端口、备份间隔、日志级别与日志目录(见 6.1) |
| 规则目录 | `%LOCALAPPDATA%\ScoreboardConsole\rules\` | 规则包 YAML;管理面板「导出 → 规则包 → 选择 YAML」时从这里选 |
**怎么改**:直接编辑上表里的文件。`config.json` 与 `config.yaml` 都是 Console 启动时读一次,改完要**退出并重启 Console**(托盘 → 退出);服务管理窗口的「配置文件」一行显示的就是 `console\config.json` 的实际路径,可用来确认。`rules\` 只是规则包存放目录,往里放文件不需要重启,但规则要在管理面板「导出 → 规则包 → 选择 YAML → 预览差异 → 确认导入并全量重算」里导入后才生效(服务端不会自动加载)。安装目录里那三份是模板,改了不生效。
@@ -141,7 +144,7 @@ deploy\build.bat
> 从老版本覆盖升级上来的第一次启动:安装目录里那三份在覆盖安装时**就已经被旧卸载器删过一遍**(新装进去的是新版模板),Console 只能把模板复制到用户目录,升级前改在安装目录里的配置需要重做一遍。**从本版本起**(也就是改动落在上表位置之后),配置与规则才会跨升级保留。
> 日志是例外:它留在安装目录的 `logs\console.log`(每次运行都会重写的排查材料,不值得搬),所以卸载后日志会消失,配置与规则不会。
> 日志分两处,别找错:Console 自己的排查日志留在安装目录的 `logs\console.log`(每次运行都会重写,不值得搬,卸载后会消失);**服务端的运行 / 放行 / 拦截日志在数据目录的 `logs\` 下**(`operations\` 与 `interceptions\` 两类,随数据目录跨升级保留,见第 10 节)。
首次启动后看 Console「运行日志」里这几行核对:
@@ -266,7 +269,10 @@ scoreboard.exe passwd -config "%LOCALAPPDATA%\ScoreboardConsole\config.yaml" -da
| `server.host` | `0.0.0.0` | 监听地址;`0.0.0.0` 表示局域网内其他设备也能访问 |
| `server.port` | `19540` | HTTP 端口 |
| `dataDir` | `data` | 数据目录,相对服务端工作目录解析;**Console 启动时会用 `-data <绝对路径>` 覆盖它**,配置项本身只对直接运行 `scoreboard.exe` 时生效 |
| `log.level` | `info` | 日志级别:`debug` / `info` / `warn` / `error` |
| `log.level` | `info` | 控制台与 `operations\` 放行类日志的采集级别:`debug` / `info` / `warn` / `error`;拦截类日志固定按 `debug` 采集,不受它影响(见第 10 节) |
| `log.dir` | 空 | 日志根目录(其下分 `operations\` / `interceptions\`);留空 = `<数据目录>\logs` |
| `log.maxSizeMB` | `20` | 单个日志文件大小上限(MB);`<=0` 用内置默认 |
| `log.keepFiles` | `10` | 每类日志保留份数(含当前文件);`<=0` 用内置默认 |
| `backup.intervalMinutes` | `5` | 自动备份间隔(分钟),`<=0` 关闭自动备份 |
| `backup.keep` | `20` | 每个库滚动保留的备份份数 |
| `server.allowedOrigins` | `[]` | 额外放行的 WebSocket Origin 白名单;默认只放行同源与不带 Origin 的客户端 |
@@ -583,12 +589,69 @@ http://<主机IP>:19540/display
---
## 10. 上线检查清单
## 10. 服务端日志与拦截复盘
服务端会把日志写到**数据目录**下的 `logs\`(默认 `%LOCALAPPDATA%\ScoreboardConsole\data\logs\`),按用途分成**两类**目录,各自按天 + 按大小轮转:
```
<数据目录>\logs\
├── operations\ # 放行类:放行的写操作 + 常规运行日志(启动、迁移…)
└── interceptions\ # 拦截类:我们主动拦截的报错 + 服务端异常
```
> 这与 Console 自己的 `logs\console.log`(在安装目录里,只装 Console 与服务端的控制台输出)不是一回事:要复盘「某次操作为什么被拒绝」,看的是数据目录 `logs\interceptions\`。
### 10.1 两类日志各记什么
| 目录 | 内容 | 采集级别 |
| --- | --- | --- |
| `operations\` | **放行类**:放行的写操作(`操作已放行`,含 action / 实体 / 操作人 / requestId)+ 常规运行日志(启动、数据库迁移…) | 按 `log.level` 过滤(默认 `info`) |
| `interceptions\` | **拦截类**:我们主动拦截的报错(WARN,含中文原因 + 拦截点 `文件:行 函数名` + method/path + 操作人/角色 + requestId)+ 拦截上下文(DEBUG,含 detail 与来源 IP)+ **服务端异常(ERROR)** | **固定按 `debug` 采集,不受 `log.level` 影响** |
拦截类固定采集是刻意的:默认级别是 `info`,跟着过滤就看不到现场最需要的那份详细拦截日志。中间件层的拦截(未登录 401 / 无权限 403 / CSRF 校验失败 / 未改初始密码 / 操作过于频繁 429)同样进这里,一样带中文原因与拦截点。
### 10.2 文件命名与轮转
- 文件名按天:`operations-YYYYMMDD.log`、`interceptions-YYYYMMDD.log`;同一天写满一个文件后接着写 `.1`、`.2`…,例如 `interceptions-20260911.1.log`;
- **保留份数含当前文件**,超出后自动删最旧的(每开一个新文件都会清理一遍);
- 单文件大小上限 `log.maxSizeMB`(默认 20 MB),每类保留份数 `log.keepFiles`(默认 10)。两类合计最多约 `2 × keepFiles × maxSizeMB`,默认约 400 MB,正常跑一整天不会把盘写满;
- 万一日志目录不可写(权限、盘满),服务端只把告警打一次,之后降级为只写控制台,**不影响业务请求**。
### 10.3 配置(`config.yaml` 的 `log:` 段)
| 项 | 默认值 | 说明 |
| --- | --- | --- |
| `log.level` | `info` | 控制台与 `operations\` 的采集级别:`debug` / `info` / `warn` / `error`;**拦截类不受它影响** |
| `log.dir` | 空 | 日志根目录,其下分 `operations\` 与 `interceptions\`;留空 = `<数据目录>\logs` |
| `log.maxSizeMB` | `20` | 单个日志文件大小上限(MB);`<=0` 用内置默认 |
| `log.keepFiles` | `10` | 每类保留份数(含当前文件);`<=0` 用内置默认 |
`dir` / `maxSizeMB` / `keepFiles` 三个键都有默认值,不配也能跑;仓库根的 `config.yaml` 里已带注释,照抄即可。改完要重启 Console(配置只在启动时读一次,见 3.5)。
### 10.4 现场复盘:某次操作被拒了怎么看
1. 打开 `<数据目录>\logs\interceptions\`,找当天的 `interceptions-YYYYMMDD.log`(有续写文件时按 `.1`、`.2` 往后看);
2. 搜 `请求被拦截`(WARN)那一行,它带:
- `reason`:中文原因(与页面上看到的提示同源);
- `origin`:**拦截点**,形如 `internal/httpapi/repo_events.go:231 normalizeAvgMode`——直接告诉你是哪个函数的哪一行拦下的;
- `code` / `method` / `path`:错误码与请求地址;
- `actor`(`用户名#id`,未登录记 `guest`)/ `role` / `requestId`;
3. 想定位得更准:在浏览器开发者工具的 **Network** 面板点开那条失败请求,复制响应头 `X-Request-Id`,在日志文件里搜这个值;同值的 DEBUG 行「拦截上下文」会再补上 `detail` 与来源 IP;
4. 想对照同一时间有没有成功的操作,去 `operations\` 里搜同一个 `requestId`(放行日志带 action / 实体 / 操作人)。
> 导入类操作的行级报错也留痕:`导入行被拦截`(DEBUG)会记第几行、原因与拦截点。日志不记请求体,证件号这类个人信息不会因为日志多一处泄露面;`detail` 字段与页面响应同源。
> 服务端自身异常(5xx)也在 `interceptions\` 里,是 `请求失败`(ERROR)并同样带 `origin`。
---
## 11. 上线检查清单
- [ ] 服务端开机自启、崩溃自动拉起已验证
- [ ] 「运行日志」里的 `数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole\data`(不是安装目录),且没有迁移警告
- [ ] 「运行日志」里的 `用户数据目录:` 指向 `%LOCALAPPDATA%\ScoreboardConsole`,且没有「准备 … 失败」警告(配置与规则也在用户目录里,见 3.5)
- [ ] 数据目录的 `backups\` 可写,已生成至少一份备份
- [ ] **服务端日志目录可写**:数据目录下已出现 `logs\operations\` 与 `logs\interceptions\` 两个目录(见第 10 节);知道操作被拒时去 `interceptions\` 搜原因与拦截点
- [ ] 已把数据目录整个拷一份到 U 盘(含 `event_projs\<项目名>\uploads\` 里的图片与字体)
- [ ] **当前赛事正确**:「导出 → 赛事项目包 → 赛事项目列表」里带「当前」徽标的是本次要用的那个项目(刚导入的包要切换过去才算生效,见 8.5)
- [ ] 大屏 HDMI 输出定位正确、分辨率正确、字体正常
+24 -1
View File
@@ -5,9 +5,12 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// Config 是 config.yaml 的映射。
@@ -37,6 +40,13 @@ type Server struct {
// Log 是日志配置。
type Log struct {
Level string `yaml:"level"`
// Dir 是日志根目录,其下分 operations/(放行类)与 interceptions/(拦截类);
// 留空时用 <dataDir>/logs。
Dir string `yaml:"dir"`
// MaxSizeMB 是单个日志文件的大小上限(MB);<=0 用 logging.DefaultMaxSizeMB。
MaxSizeMB int `yaml:"maxSizeMB"`
// KeepFiles 是每个类别保留的份数(含当前文件);<=0 用 logging.DefaultKeepFiles。
KeepFiles int `yaml:"keepFiles"`
}
// Default 返回一份可直接使用的默认配置。
@@ -45,10 +55,23 @@ func Default() *Config {
Server: Server{Host: "0.0.0.0", Port: 19540},
DataDir: "data",
Backup: Backup{IntervalMinutes: 5, Keep: 20},
Log: Log{Level: "info"},
Log: Log{
Level: "info",
MaxSizeMB: logging.DefaultMaxSizeMB,
KeepFiles: logging.DefaultKeepFiles,
},
}
}
// DirPath 返回日志根目录:配了 log.dir 就用它,否则落在数据目录下的 logs/。
// 相对路径保持相对(与 dataDir 的处理一致),现场怎么填就怎么用。
func (l Log) DirPath(dataDir string) string {
if dir := strings.TrimSpace(l.Dir); dir != "" {
return dir
}
return filepath.Join(dataDir, "logs")
}
// Load 读取配置文件;文件不存在时返回默认配置。
func Load(path string) (*Config, error) {
cfg := Default()
+30
View File
@@ -63,3 +63,33 @@ func TestApplyDataDir(t *testing.T) {
})
}
}
// 日志配置:默认值开箱即用,配了 log.dir 就按它走,不配就落在 <dataDir>/logs。
func TestLogConfigDefaultsAndDirPath(t *testing.T) {
def := Default().Log
if def.Level != "info" {
t.Fatalf("默认级别应为 info,得到 %q", def.Level)
}
if def.MaxSizeMB <= 0 || def.KeepFiles <= 0 {
t.Fatalf("大小上限与保留份数都要有默认值,得到 %+v", def)
}
if got := def.DirPath("/data/sb"); got != filepath.Join("/data/sb", "logs") {
t.Fatalf("未配 dir 时应落在数据目录下的 logs,得到 %q", got)
}
path := filepath.Join(t.TempDir(), "config.yaml")
raw := "log:\n level: warn\n dir: /var/log/scoreboard\n maxSizeMB: 5\n keepFiles: 3\n"
if err := os.WriteFile(path, []byte(raw), 0o644); err != nil {
t.Fatalf("写入配置: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("加载配置: %v", err)
}
if cfg.Log.Level != "warn" || cfg.Log.MaxSizeMB != 5 || cfg.Log.KeepFiles != 3 {
t.Fatalf("log 段解析错误: %+v", cfg.Log)
}
if got := cfg.Log.DirPath("/data/sb"); got != "/var/log/scoreboard" {
t.Fatalf("配了 dir 就该用它,得到 %q", got)
}
}
+5 -7
View File
@@ -60,8 +60,7 @@ func isBuiltinFontID(id string) bool {
func (s *server) fontsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法管理字体"}
return "", serviceUnavailable("STATE_INVALID", "当前未配置赛事项目目录,无法管理字体")
}
return filepath.Join(dir, "uploads", "fonts"), nil
}
@@ -247,8 +246,7 @@ func (s *server) uploadFontHandler(w http.ResponseWriter, r *http.Request) (any,
// fontTooLarge 返回 413:字体超过上限时必须给出状态码,前端才知道是「太大」而不是「格式不对」。
func fontTooLarge() *apiError {
return &apiError{status: http.StatusRequestEntityTooLarge, code: "VALIDATION_FAILED",
message: "字体文件超过 20 MiB 上限"}
return tooLarge("字体文件超过 20 MiB 上限")
}
// deleteFontHandler 删除上传字体;仍被 display.font_title / display.font_body 引用时拒绝。
@@ -296,17 +294,17 @@ func (s *server) serveFont(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := fontContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("字体不存在"))
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
dir, err := s.fontsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("字体不存在"))
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("字体不存在"))
s.writeAPIError(w, r, notFound("字体不存在"))
return
}
w.Header().Set("Content-Type", ctype)
+22 -24
View File
@@ -41,9 +41,9 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) (any, error) {
u, token, err := s.auth.Authenticate(r.Context(), in.Username, in.Password, ip, r.UserAgent())
switch {
case err == auth.ErrRateLimited:
return nil, &apiError{status: http.StatusTooManyRequests, code: "RATE_LIMITED", message: "登录尝试过于频繁,请稍后再试"}
return nil, rateLimited("登录尝试过于频繁,请稍后再试")
case err == auth.ErrInvalidCredentials, err == auth.ErrUserInactive:
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: err.Error()}
return nil, unauthorized("AUTH_FAILED", err.Error())
case err != nil:
return nil, internal(err)
}
@@ -87,7 +87,7 @@ type changePasswordInput struct {
func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, error) {
u := middleware.User(r.Context())
if u.Role == auth.RoleGuest {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
return nil, unauthorized("AUTH_REQUIRED", "请先登录")
}
var in changePasswordInput
@@ -105,7 +105,7 @@ func (s *server) changePassword(w http.ResponseWriter, r *http.Request) (any, er
}
// 会话被盗后不能直接改密,必须再验一次原密码
if err := s.auth.VerifyPassword(r.Context(), u.ID, in.CurrentPassword); err != nil {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_FAILED", message: "原密码不正确"}
return nil, unauthorized("AUTH_FAILED", "原密码不正确")
}
if err := s.auth.SetPassword(r.Context(), u.Username, in.NewPassword, false); err != nil {
return nil, internal(err)
@@ -168,8 +168,7 @@ func (s *server) listTeamsHandler(w http.ResponseWriter, r *http.Request) (any,
includeDeleted := isQueryTrue(r, "includeDeleted")
// 已彻底删除的单位不进任何列表,只有 admin 能用显式开关把它们翻出来排查。
if includeDeleted && !middleware.User(r.Context()).Role.Allows(auth.RoleAdmin) {
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN",
message: "只有管理员可以查看已彻底删除的单位"}
return nil, forbidden("只有管理员可以查看已彻底删除的单位")
}
return s.listTeams(r.Context(), includeDeleted)
}
@@ -819,34 +818,34 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
athleteName := cell("athlete_name")
if eventCode == "" || teamCode == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:event_code / team_code 必填", line))
result.Errors = append(result.Errors, s.interceptRow(line, "event_code / team_code 必填"))
continue
}
// status 先归一:填表人写 DNS、退赛这类值不会撞 CHECK,认不出的填法在这里给出行级提示。
status, err := normalizeImportStatus(cell("status"))
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
eventID, err := s.lookupID(ctx, `SELECT id FROM events WHERE code = ?`, eventCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 不存在", line, eventCode))
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 不存在", eventCode))
continue
}
teamID, err := s.lookupID(ctx, `SELECT id FROM teams WHERE code = ?`, teamCode)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:单位 %s 不存在", line, teamCode))
result.Errors = append(result.Errors, s.interceptRow(line, "单位 %s 不存在", teamCode))
continue
}
// 编码查单位时不排除已隐藏的(编码有唯一索引,排除会撞索引或重复建档),
// 命中就复活他:这一行马上要给他建报名,人却看不见就说不通了。
if err := s.reviveTeam(ctx, teamID); err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
var categoryID int64
@@ -854,14 +853,14 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? AND name = ?`, eventID, categoryName)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:组别 %s 不存在", line, categoryName))
result.Errors = append(result.Errors, s.interceptRow(line, "组别 %s 不存在", categoryName))
continue
}
} else {
categoryID, err = s.lookupID(ctx, `SELECT id FROM event_categories WHERE event_id = ? ORDER BY sort_order, id LIMIT 1`, eventID)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:小项 %s 没有组别", line, eventCode))
result.Errors = append(result.Errors, s.interceptRow(line, "小项 %s 没有组别", eventCode))
continue
}
}
@@ -874,19 +873,19 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
// 证件号与号码布二选一即可,都没有就无法区分同名的两个人。
if idCard == "" && bib == "" {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:填写了 athlete_name 就必须填 id_card 或 bib", line))
result.Errors = append(result.Errors, s.interceptRow(line, "填写了 athlete_name 就必须填 id_card 或 bib"))
continue
}
age, ok := parseImportAge(cell("age"))
if !ok {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:age 需要是 0-120 的整数", line))
result.Errors = append(result.Errors, s.interceptRow(line, "age 需要是 0-120 的整数"))
continue
}
if idCard != "" {
if seenIDCards[idCard] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:证件号 %s 在报名表内重复出现", line, idCard))
result.Errors = append(result.Errors, s.interceptRow(line, "证件号 %s 在报名表内重复出现", idCard))
continue
}
seenIDCards[idCard] = true
@@ -894,7 +893,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
if bib != "" {
if seenBibs[bib] {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:号码布 %s 在报名表内重复出现", line, bib))
result.Errors = append(result.Errors, s.interceptRow(line, "号码布 %s 在报名表内重复出现", bib))
continue
}
seenBibs[bib] = true
@@ -912,7 +911,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
}, decisions)
if err != nil {
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
continue
}
if matched.Conflict != nil {
@@ -937,7 +936,7 @@ func (s *server) importEntriesWith(ctx context.Context, rows [][]string, decisio
switch {
case err != nil:
result.Skipped++
result.Errors = append(result.Errors, fmt.Sprintf("第 %d 行:%s", line, err.Error()))
result.Errors = append(result.Errors, s.interceptRow(line, "%s", err.Error()))
case created:
result.Created++
default:
@@ -1015,8 +1014,7 @@ type uploadResult struct {
func (s *server) uploadsDir() (string, error) {
dir := strings.TrimSpace(s.projectDir)
if dir == "" {
return "", &apiError{status: http.StatusServiceUnavailable, code: "STATE_INVALID",
message: "当前未配置赛事项目目录,无法上传图片"}
return "", serviceUnavailable("STATE_INVALID", "当前未配置赛事项目目录,无法上传图片")
}
return filepath.Join(dir, "uploads"), nil
}
@@ -1092,18 +1090,18 @@ func (s *server) serveUpload(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
ctype, ok := uploadContentType(name)
if !ok {
writeAPIError(w, s.logger, notFound("图片不存在"))
s.writeAPIError(w, r, notFound("图片不存在"))
return
}
dir, err := s.uploadsDir()
if err != nil {
writeAPIError(w, s.logger, notFound("图片不存在"))
s.writeAPIError(w, r, notFound("图片不存在"))
return
}
// 名字已过白名单校验,这里再用 Base 兜一层,确保只会读到 uploads 内的文件。
file := filepath.Join(dir, filepath.Base(name))
if info, err := os.Stat(file); err != nil || info.IsDir() {
writeAPIError(w, s.logger, notFound("图片不存在"))
s.writeAPIError(w, r, notFound("图片不存在"))
return
}
w.Header().Set("Content-Type", ctype)
+3 -3
View File
@@ -36,12 +36,12 @@ func (s *server) shutdownHandler(w http.ResponseWriter, r *http.Request) (any, e
if !ok {
// 没带任何凭据的访客按「需登录」处理,其余一律 403,避免探测。
if middleware.User(r.Context()).Role == auth.RoleGuest && r.Header.Get(shutdownTokenHeader) == "" {
return nil, &apiError{status: http.StatusUnauthorized, code: "AUTH_REQUIRED", message: "请先登录"}
return nil, unauthorized("AUTH_REQUIRED", "请先登录")
}
return nil, &apiError{status: http.StatusForbidden, code: "FORBIDDEN", message: "无权关闭服务"}
return nil, forbidden("无权关闭服务")
}
if s.shutdown == nil {
return nil, &apiError{status: http.StatusServiceUnavailable, code: "INTERNAL", message: "服务端未接入优雅关闭"}
return nil, serviceUnavailable("INTERNAL", "服务端未接入优雅关闭")
}
s.audit(r.Context(), "system.shutdown", "system", 0, nil, map[string]any{"auth": method})
@@ -0,0 +1,203 @@
package httpapi_test
import (
"bytes"
"log/slog"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
)
// logEnv 把服务端日志抓进内存缓冲:日志分级与拦截点只有真的抓到才能断言。
// 用 DEBUG 级别建 handler,WARN/INFO/ERROR 与默认级别下的可见性差异由用例分别断言。
func logEnv(t *testing.T) (*testEnv, *bytes.Buffer) {
t.Helper()
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Logger = logger })
return env, buf
}
// waitLog 等到日志里出现 want 之后返回整段日志。
// 一部分日志(审计 INFO、拦截 WARN)在响应写完之后才落,不能立刻就读缓冲。
func waitLog(t *testing.T, buf *bytes.Buffer, want string) string {
t.Helper()
for i := 0; i < 200; i++ {
if got := buf.String(); strings.Contains(got, want) {
return got
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等不到日志片段 %q,当前日志:\n%s", want, buf.String())
return ""
}
// recorderClient 建号并登录,返回 recorder 客户端。
func recorderClient(t *testing.T, env *testEnv) *http.Client {
t.Helper()
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
return client
}
// 拦截点必须指向真正做校验的那段代码,而不是 response.go 里的构造函数。
func TestInterceptLogPointsAtValidator(t *testing.T) {
env, buf := logEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
// avgMode 校验发生在 repo_events.go 的 normalizeAvgMode。
status, env2 := env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E-BAD", "name": "非法小项", "avgMode": "bogus",
}, true)
if status != http.StatusBadRequest || env2.Error == nil {
t.Fatalf("非法 avgMode 应 400,得到 %d %+v", status, env2.Error)
}
logs := waitLog(t, buf, "请求被拦截")
if !strings.Contains(logs, `"origin":"internal/httpapi/repo_events.go:`) ||
!strings.Contains(logs, "normalizeAvgMode") {
t.Fatalf("WARN 行应指出拦截点是 normalizeAvgMode,得到:\n%s", logs)
}
for _, forbidden := range []string{"response.go", "badRequest", "newAPIError", "/home/"} {
if strings.Contains(logs, forbidden) {
t.Fatalf("拦截点不该出现 %q(应指向校验代码、且不带开发机路径),得到:\n%s", forbidden, logs)
}
}
}
// 分级:4xx 有 WARN 与 DEBUG、没有 ERROR;5xx 用 ERROR;成功的写操作只有 INFO 放行。
func TestInterceptLogLevels(t *testing.T) {
t.Run("4xx", func(t *testing.T) {
env, buf := logEnv(t)
ids := env.seed(t)
client := recorderClient(t, env)
env.do(client, http.MethodPost, "/api/events", map[string]any{
"sportId": ids.sport, "code": "E-BAD", "name": "非法小项", "avgMode": "bogus",
}, true)
logs := waitLog(t, buf, `"level":"WARN"`)
// WARN 是默认 info 级别下可见的那行:为什么被拦 + 拦截点都要在里面。
if !strings.Contains(logs, `"reason":"avgMode 只能是 valid / all"`) {
t.Fatalf("WARN 行应写明被拦原因,得到:\n%s", logs)
}
if !strings.Contains(logs, `"origin":"internal/httpapi/repo_events.go:`) {
t.Fatalf("WARN 行应带拦截点,得到:\n%s", logs)
}
logs = waitLog(t, buf, `"level":"DEBUG"`)
if !strings.Contains(logs, `"msg":"拦截上下文"`) || !strings.Contains(logs, `"detail":"bogus"`) {
t.Fatalf("DEBUG 行应带完整上下文与 detail,得到:\n%s", logs)
}
if strings.Contains(logs, `"level":"ERROR"`) {
t.Fatalf("4xx 不该出现 ERROR,得到:\n%s", logs)
}
})
t.Run("5xx", func(t *testing.T) {
sender := &strictDisplaySender{}
buf := &bytes.Buffer{}
logger := slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Logger = logger
o.Display = sender
})
client := recorderClient(t, env)
status, _ := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "nope"}, true)
if status != http.StatusInternalServerError {
t.Fatalf("未知指令应 500,得到 %d", status)
}
logs := waitLog(t, buf, `"level":"ERROR"`)
if !strings.Contains(logs, `"origin":"internal/httpapi/handlers_results.go:`) ||
!strings.Contains(logs, "displayCommandHandler") {
t.Fatalf("ERROR 行应带上拦截点,得到:\n%s", logs)
}
if strings.Contains(logs, `"level":"WARN"`) {
t.Fatalf("5xx 不该同时按 WARN 记,得到:\n%s", logs)
}
})
t.Run("放行", func(t *testing.T) {
env, buf := logEnv(t)
client := recorderClient(t, env)
status, env2 := env.do(client, http.MethodPost, "/api/teams", map[string]any{
"code": "Z9", "name": "第九分公司",
}, true)
if status != http.StatusOK || !env2.OK {
t.Fatalf("新建单位应 200,得到 %d %+v", status, env2.Error)
}
logs := waitLog(t, buf, "操作已放行")
for _, want := range []string{`"level":"INFO"`, `"action":"team.create"`, `"entity":"team"`, `"actor":"recorder#`} {
if !strings.Contains(logs, want) {
t.Fatalf("放行日志应含 %s,得到:\n%s", want, logs)
}
}
// 一次审计条目一行 INFO:登录 + 新建单位共 2 行,别在同一次请求里重复刷。
if n := strings.Count(logs, "操作已放行"); n != 2 {
t.Fatalf("放行日志应恰好 2 行(登录 + 新建单位),得到 %d 行:\n%s", n, logs)
}
if strings.Contains(logs, `"level":"WARN"`) || strings.Contains(logs, `"level":"ERROR"`) {
t.Fatalf("成功请求不该有 WARN/ERROR,得到:\n%s", logs)
}
})
}
// 日志不能整段记请求体:带证件号的新建人员请求(成功与 409 两条路径)都不该把证件号打出来。
func TestInterceptLogKeepsBodyOut(t *testing.T) {
env, buf := logEnv(t)
client := recorderClient(t, env)
const idCard = "110101199001019999"
env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": 1, "idCard": idCard, "active": true,
}, true)
// 同一个证件号再来一次:409 的 WARN 也不该带出证件号。
env.do(client, http.MethodPost, "/api/athletes", map[string]any{
"name": "张三", "gender": "男", "teamId": 1, "idCard": idCard, "active": true,
}, true)
logs := waitLog(t, buf, "操作已放行")
if !strings.Contains(logs, "请求被拦截") {
t.Fatalf("第二次建档应被拦截并记 WARN,得到:\n%s", logs)
}
if strings.Contains(logs, idCard) {
t.Fatalf("日志里出现了证件号原文,说明请求体被打了进去:\n%s", logs)
}
}
// 导入的行级拦截每一条都要有一行 DEBUG,带行号与拦截点。
func TestImportRowInterceptLogs(t *testing.T) {
env, buf := logEnv(t)
env.seed(t)
client := recorderClient(t, env)
body := importHeader + "\n" +
"M100,男子,A,张三,110101199001011234,男,,,,已报到,\n" +
"M100,男子,A,李四,110101199001011235,男,,,,,\n"
status, resp := env.postImport(t, client, "/api/entries/import", body, "")
if status != http.StatusOK {
t.Fatalf("导入应 200,得到 %d %+v", status, resp.Error)
}
result := decodeData[map[string]any](t, resp)
if result["skipped"] != float64(1) {
t.Fatalf("非法 status 的那行应被跳过: %+v", result)
}
logs := waitLog(t, buf, "导入行被拦截")
if !strings.Contains(logs, `"row":2`) || !strings.Contains(logs, "无法识别") {
t.Fatalf("行级 DEBUG 应带行号与原因,得到:\n%s", logs)
}
if !strings.Contains(logs, "internal/httpapi/handlers_config.go:") ||
!strings.Contains(logs, "importEntriesWith") {
t.Fatalf("行级 DEBUG 应带拦截点,得到:\n%s", logs)
}
if n := strings.Count(logs, "导入行被拦截"); n != 1 {
t.Fatalf("一条行级错误应只记一行 DEBUG,得到 %d 行:\n%s", n, logs)
}
}
+164
View File
@@ -0,0 +1,164 @@
package httpapi_test
import (
"bytes"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// fileLogEnv 把服务端日志按「放行 / 拦截」两类真的写进临时目录,返回两个目录路径。
// 到这里为止的用例都只看内存缓冲,这一份是「真的读文件」的验收:分流、级别、落盘都要落地。
func fileLogEnv(t *testing.T) (*testEnv, string, string) {
t.Helper()
dir := t.TempDir()
handler, closer := logging.New(logging.Options{
Dir: dir,
Level: slog.LevelInfo,
MaxSizeMB: 20,
KeepFiles: 10,
Console: &bytes.Buffer{},
})
t.Cleanup(func() { _ = closer.Close() })
env := newTestEnvWith(t, func(o *httpapi.Options) { o.Logger = slog.New(handler) })
return env, filepath.Join(dir, logging.DirOperations), filepath.Join(dir, logging.DirInterceptions)
}
// readCategory 读某类日志今天的文件;文件还没建出来时返回空串。
func readCategory(t *testing.T, dir, prefix string) string {
t.Helper()
path := filepath.Join(dir, prefix+"-"+time.Now().Format("20060102")+".log")
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatalf("读日志 %s: %v", path, err)
}
return string(raw)
}
// waitCategory 等到某类日志出现目标片段(审计 INFO 在响应写完之后才落)。
func waitCategory(t *testing.T, dir, prefix, want string) string {
t.Helper()
for i := 0; i < 200; i++ {
got := readCategory(t, dir, prefix)
if strings.Contains(got, want) {
return got
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("等不到 %s/%s 里出现 %q,当前内容:\n%s", dir, prefix, want, readCategory(t, dir, prefix))
return ""
}
// 中间件层的拦截(403/401 这类发生在 handler 之前的拦截)必须落进 interceptions/,
// 带中文原因与指向中间件的拦截点;同时验证两类文件互不串门、5xx 只进拦截类。
func TestMiddlewareInterceptsLandInInterceptionFile(t *testing.T) {
env, opsDir, interDir := fileLogEnv(t)
env.seed(t)
// recorder 访问 admin-only 的只读接口 → RequireRole 403。
env.createUser("recorder", "recorder-pass", "recorder")
recorder, _ := env.client()
env.login(recorder, "recorder", "recorder-pass")
if status, _ := env.do(recorder, http.MethodGet, "/api/audit-logs", nil, false); status != http.StatusForbidden {
t.Fatalf("recorder 读审计应 403,得到 %d", status)
}
// 已登录但不带 CSRF 头的写请求 → CSRF 中间件 403。
if status, _ := env.do(recorder, http.MethodPost, "/api/teams",
map[string]any{"code": "X", "name": "越权单位"}, false); status != http.StatusForbidden {
t.Fatalf("缺 CSRF 的写请求应 403,得到 %d", status)
}
// 必须改密的账号写操作 → MustChangePassword 403。
lockedID := env.createUser("locked", "locked-pass", "recorder")
if _, err := env.sys.Exec(`UPDATE users SET must_change_password = 1 WHERE id = ?`, lockedID); err != nil {
t.Fatalf("设置 must_change_password: %v", err)
}
locked, _ := env.client()
env.login(locked, "locked", "locked-pass")
if status, _ := env.do(locked, http.MethodPost, "/api/teams",
map[string]any{"code": "Y", "name": "越权单位"}, true); status != http.StatusForbidden {
t.Fatalf("改密前写操作应 403,得到 %d", status)
}
// 未登录的写请求 → RequireWrite 401。
guest, _ := env.client()
if status, _ := env.do(guest, http.MethodPost, "/api/teams",
map[string]any{"code": "Z", "name": "未登录"}, false); status != http.StatusUnauthorized {
t.Fatalf("guest 写请求应 401,得到 %d", status)
}
inter := waitCategory(t, interDir, logging.DirInterceptions, "RequireRole")
for _, want := range []string{
"请求被拦截",
"无权访问该资源",
"请先登录",
"CSRF 校验失败,请刷新页面重试",
"请先修改初始密码",
"internal/middleware/middleware.go:",
} {
if !strings.Contains(inter, want) {
t.Fatalf("interceptions 应含 %q,得到:\n%s", want, inter)
}
}
// 四个中间件各留下一条 WARN,拦截点都在 middleware.go、都不是 response.go。
for _, fn := range []string{"RequireRole", "RequireWrite", "CSRF", "MustChangePassword"} {
if !strings.Contains(inter, fn) {
t.Fatalf("interceptions 应含拦截点函数 %s,得到:\n%s", fn, inter)
}
}
if strings.Contains(inter, "response.go") {
t.Fatalf("中间件拦截的拦截点不该落在 response.go,得到:\n%s", inter)
}
// 放行类里不能出现这些拦截行。
ops := readCategory(t, opsDir, logging.DirOperations)
for _, unwanted := range []string{"请求被拦截", "无权访问该资源", "CSRF 校验失败"} {
if strings.Contains(ops, unwanted) {
t.Fatalf("operations 不该含拦截日志 %q,得到:\n%s", unwanted, ops)
}
}
// 放行类里应当有登录这条 INFO(证明两类文件各自都有内容)。
if !strings.Contains(waitCategory(t, opsDir, logging.DirOperations, "操作已放行"), `"action":"auth.login"`) {
t.Fatalf("operations 应含放行日志,得到:\n%s", readCategory(t, opsDir, logging.DirOperations))
}
}
// 5xx 的归属:只进 interceptions/,不进 operations/。
func TestServerErrorGoesToInterceptionFileOnly(t *testing.T) {
dir := t.TempDir()
sender := &strictDisplaySender{}
handler, closer := logging.New(logging.Options{Dir: dir, Level: slog.LevelInfo, Console: &bytes.Buffer{}})
defer func() { _ = closer.Close() }()
env := newTestEnvWith(t, func(o *httpapi.Options) {
o.Logger = slog.New(handler)
o.Display = sender
})
env.createUser("recorder", "recorder-pass", "recorder")
client, _ := env.client()
env.login(client, "recorder", "recorder-pass")
if status, _ := env.do(client, http.MethodPost, "/api/display/command",
map[string]any{"action": "nope"}, true); status != http.StatusInternalServerError {
t.Fatalf("未知指令应 500,得到 %d", status)
}
inter := waitCategory(t, filepath.Join(dir, logging.DirInterceptions), logging.DirInterceptions, "请求失败")
if !strings.Contains(inter, "displayCommandHandler") {
t.Fatalf("ERROR 行应带拦截点,得到:\n%s", inter)
}
ops := readCategory(t, filepath.Join(dir, logging.DirOperations), logging.DirOperations)
if strings.Contains(ops, "请求失败") {
t.Fatalf("5xx 不该进 operations,得到:\n%s", ops)
}
}
+91
View File
@@ -0,0 +1,91 @@
package httpapi
import (
"fmt"
"log/slog"
"net/http"
"runtime"
"strings"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
)
// 本文件把「拦截点」(哪一行、哪个函数拦下的)记进拦截日志。
// 主动拦截的报错必须自带拦截点:默认级别是 info,现场只看得见 WARN 那一行,
// 只给一句中文提示的话,排查时仍然不知道是哪段校验拦的。
// 捕获实现放在 internal/logging,与 middleware 共用一份,避免两边各写一套。
// callerOrigin 捕获调用点;skip 从 callerOrigin 自己算起(见 newAPIError 的说明),
// 这里 +1 是把自己这一层算进 logging.Origin 的帧数。
func callerOrigin(skip int) string {
return logging.Origin(skip + 1)
}
// originFromStack 兜底:错误不是经构造函数造的(例如把底层错误原样返回出来)时,
// 沿栈找到第一个不在 response.go / 本文件里的帧,就是返回这个错误的那段业务代码。
func originFromStack(skip int) string {
pcs := make([]uintptr, 16)
n := runtime.Callers(skip, pcs)
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
if frame.File != "" && !strings.HasSuffix(frame.File, "response.go") &&
!strings.HasSuffix(frame.File, "origin.go") && !strings.Contains(frame.Function, "/internal/logging.") {
return logging.FormatOrigin(frame.File, frame.Line, frame.Function)
}
if !more {
return "unknown"
}
}
}
// interceptRow 生成给前端的行级错误文案,并顺手留一条 DEBUG 拦截日志(拦进 interceptions/)。
// 导入的行级错误原本只进响应体,服务端不留痕的话,现场「我明明填对了它却说格式不对」没法复盘。
func (s *server) interceptRow(row int, format string, args ...any) string {
reason := fmt.Sprintf(format, args...)
s.logger.Debug("导入行被拦截",
"row", row,
"reason", reason,
"origin", callerOrigin(2),
logging.ChannelKey, logging.ChannelInterception,
)
return fmt.Sprintf("第 %d 行:%s", row, reason)
}
// logIntercepted 记录一次被拦截的请求。
// WARN 是默认级别(info)下现场唯一看得见的一行,所以「为什么被拦」与「拦截点」都放在它身上;
// DEBUG 只补上下文(code/detail/ip/角色),不能只靠它。带类别标记的行会进 interceptions/。
// 这里绝不整段记请求体:里面有证件号这类个人信息,`detail` 与响应同源,属既有泄露面。
func logIntercepted(r *http.Request, logger *slog.Logger, api *apiError) {
u := middleware.User(r.Context())
actor := actorLabel(u)
role := u.Role.String()
reqID := middleware.GetRequestID(r.Context())
if api.status >= http.StatusInternalServerError {
logger.Error("请求失败",
"code", api.code, "err", api.message, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "actor", actor, "requestId", reqID)
return
}
logger.Warn("请求被拦截",
"reason", api.message, "origin", api.origin, "code", api.code,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
logger.Debug("拦截上下文",
"code", api.code, "detail", api.detail, "origin", api.origin,
"method", r.Method, "path", r.URL.Path, "ip", middleware.ClientIP(r),
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
}
// actorLabel 是日志里的操作人:未登录记 guest,登录后记「用户名#id」,比裸 id 好认。
func actorLabel(u *auth.User) string {
if u == nil || u.ID == 0 {
return "guest"
}
return fmt.Sprintf("%s#%d", u.Username, u.ID)
}
+63 -12
View File
@@ -3,7 +3,6 @@ package httpapi
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -17,32 +16,78 @@ type apiError struct {
code string
message string
detail any
// origin 是「拦截点」:构造这个错误的 file:line 与函数名,例如
// internal/httpapi/repo_events.go:231 normalizeAvgMode。拦截日志靠它定位到校验代码。
origin string
}
func (e *apiError) Error() string { return e.code + ": " + e.message }
// newAPIError 是所有业务错误的唯一构造入口:在这里捕获拦截点。
// skip=3 是 callerOrigin → newAPIError → 具体构造函数 → 真正做校验的调用点,
// 少一层就会把 helper 自己记成拦截点,那样日志等于没说。
func newAPIError(status int, code, message string, detail any) *apiError {
return &apiError{
status: status,
code: code,
message: message,
detail: detail,
origin: callerOrigin(3),
}
}
func badRequest(message string, detail any) *apiError {
return &apiError{status: http.StatusBadRequest, code: "VALIDATION_FAILED", message: message, detail: detail}
return newAPIError(http.StatusBadRequest, "VALIDATION_FAILED", message, detail)
}
func notFound(message string) *apiError {
return &apiError{status: http.StatusNotFound, code: "VALIDATION_FAILED", message: message}
return newAPIError(http.StatusNotFound, "VALIDATION_FAILED", message, nil)
}
func conflict(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "CONFLICT", message: message}
return newAPIError(http.StatusConflict, "CONFLICT", message, nil)
}
func stateInvalid(message string) *apiError {
return &apiError{status: http.StatusConflict, code: "STATE_INVALID", message: message}
return newAPIError(http.StatusConflict, "STATE_INVALID", message, nil)
}
// unauthorized 是 401:code 区分「没登录」与「凭据不对」。
func unauthorized(code, message string) *apiError {
return newAPIError(http.StatusUnauthorized, code, message, nil)
}
// forbidden 是 403:角色不足或来源校验不过。
func forbidden(message string) *apiError {
return newAPIError(http.StatusForbidden, "FORBIDDEN", message, nil)
}
// rateLimited 是 429:写操作/登录太频繁,稍后可重试。
func rateLimited(message string) *apiError {
return newAPIError(http.StatusTooManyRequests, "RATE_LIMITED", message, nil)
}
// tooLarge 是 413:正文超过上限,与「格式不对」区分开,前端才知道该怎么处理。
func tooLarge(message string) *apiError {
return newAPIError(http.StatusRequestEntityTooLarge, "VALIDATION_FAILED", message, nil)
}
// serviceUnavailable 是 503:依赖未接入或目录未配置,配置好即可用,重试无用。
func serviceUnavailable(code, message string) *apiError {
return newAPIError(http.StatusServiceUnavailable, code, message, nil)
}
func internal(err error) *apiError {
return &apiError{status: http.StatusInternalServerError, code: "INTERNAL", message: "服务端异常", detail: err.Error()}
return newAPIError(http.StatusInternalServerError, "INTERNAL", "服务端异常", err.Error())
}
// internalMsg 是 internal 的可自定义文案版本:5xx 的 message 也要让现场看懂发生了什么。
func internalMsg(message string, err error) *apiError {
return newAPIError(http.StatusInternalServerError, "INTERNAL", message, err.Error())
}
func notImplemented(message string) *apiError {
return &apiError{status: http.StatusNotImplemented, code: "INTERNAL", message: message}
return newAPIError(http.StatusNotImplemented, "INTERNAL", message, nil)
}
// handler 是业务处理器:返回 data 时自动包成 {ok:true,data},返回 *apiError 时输出统一错误。
@@ -56,7 +101,7 @@ func (s *server) handle(fn handler) http.HandlerFunc {
if errors.Is(err, errHandled) {
return
}
writeAPIError(w, s.logger, err)
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": data})
@@ -67,21 +112,27 @@ func (s *server) handle(fn handler) http.HandlerFunc {
func (s *server) handleVoid(fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := fn(w, r); err != nil {
writeAPIError(w, s.logger, err)
s.writeAPIError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": nil})
}
}
func writeAPIError(w http.ResponseWriter, logger *slog.Logger, err error) {
// writeAPIError 把业务错误写成统一响应,并按级别记日志:
// 4xx 是「我们主动拦下的」,WARN 给出原因与拦截点(默认 info 级别下必须可见),DEBUG 补上下文;
// 5xx 是服务端自身异常,用 ERROR 并带上拦截点。
func (s *server) writeAPIError(w http.ResponseWriter, r *http.Request, err error) {
var api *apiError
if !errors.As(err, &api) {
api = internal(err)
}
if api.status >= http.StatusInternalServerError {
logger.Error("请求失败", "code", api.code, "err", api.message)
// 兜底:错误没经构造函数(拿不到 origin)时,沿栈找到返回它的那段业务代码。
if api.origin == "" {
api.origin = originFromStack(3)
}
logIntercepted(r, s.logger, api)
body := map[string]any{
"ok": false,
"error": map[string]any{
+1 -6
View File
@@ -139,12 +139,7 @@ func (s *server) importRules(w http.ResponseWriter, r *http.Request) (any, error
// 规则已经落库、重算整体回滚,现场处于「新规则 + 旧积分」;
// 必须显式告知调用方,不能报成功让操作员以为重算已完成。
s.logger.Error("规则导入后重算失败", "err", err)
return nil, &apiError{
status: http.StatusInternalServerError,
code: "INTERNAL",
message: "规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入",
detail: err.Error(),
}
return nil, internalMsg("规则已导入,但积分重算失败,成绩与积分尚未同步;请重试导入", err)
}
s.audit(r.Context(), "rules.import", "rules", 0, nil, res)
return rulesImportResult{
+8 -8
View File
@@ -126,9 +126,9 @@ func New(opts Options) http.Handler {
var h http.Handler = mux
h = s.broadcastChanges(h)
h = middleware.NewAuditor(opts.Event, logger).Middleware(h)
h = middleware.RateLimitWrite(svc)(h)
h = middleware.CSRF(h)
h = middleware.MustChangePassword(h)
h = middleware.RateLimitWrite(svc, logger)(h)
h = middleware.CSRF(logger, h)
h = middleware.MustChangePassword(logger, h)
h = middleware.Authenticate(svc, logger)(h)
h = middleware.Log(logger)(h)
h = middleware.Recover(logger)(h)
@@ -149,17 +149,17 @@ func (s *server) public(fn handler) http.HandlerFunc { return s.handle(fn) }
// read 注册「guest 也不可读」的接口,权限不足一律 403。
func (s *server) read(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireRole(role)(s.handle(fn)).ServeHTTP
return middleware.RequireRole(s.logger, role)(s.handle(fn)).ServeHTTP
}
// write 注册写接口:未登录 401,角色不足 403;成功后按 X-Request-Id 支持幂等回放。
func (s *server) write(role auth.Role, fn handler) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handle(fn))).ServeHTTP
return middleware.RequireWrite(s.logger, role)(s.cached(s.handle(fn))).ServeHTTP
}
// writeVoid 注册无返回体的写接口。
func (s *server) writeVoid(role auth.Role, fn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return middleware.RequireWrite(role)(s.cached(s.handleVoid(fn))).ServeHTTP
return middleware.RequireWrite(s.logger, role)(s.cached(s.handleVoid(fn))).ServeHTTP
}
func (s *server) routes(mux *http.ServeMux) {
@@ -269,14 +269,14 @@ func (s *server) routes(mux *http.ServeMux) {
mux.HandleFunc("/api/export/", s.notWired("导出"))
mux.HandleFunc("/api/rules/", s.notWired("规则导入导出"))
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notFound("接口不存在"))
s.writeAPIError(w, r, notFound("接口不存在"))
})
}
// notWired 为待接线的接口返回 501,避免请求被静态资源兜底成 HTML。
func (s *server) notWired(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, s.logger, notImplemented(name+"接口尚未接入"))
s.writeAPIError(w, r, notImplemented(name+"接口尚未接入"))
}
}
+151
View File
@@ -0,0 +1,151 @@
package logging
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"sync"
)
// 默认值:现场不配 config.yaml 的 log 段也能开箱即用,且不会把盘写满。
const (
// DirOperations 与 DirInterceptions 是两类日志的子目录名。
DirOperations = "operations"
DirInterceptions = "interceptions"
DefaultMaxSizeMB = 20
DefaultKeepFiles = 10
operationsPrefix = "operations"
interceptionPrefix = "interceptions"
)
// Options 是日志装配参数。
type Options struct {
// Dir 是日志根目录,其下分 operations/ 与 interceptions/ 两个子目录。
Dir string
// Level 是放行类(与控制台)的采集级别,来自 config.yaml 的 log.level。
Level slog.Level
// MaxSizeMB 是单个文件的大小上限(MB),<=0 用默认值。
MaxSizeMB int
// KeepFiles 是每个类别保留的份数(含当前文件),<=0 用默认值。
KeepFiles int
// Console 是控制台输出目标,留空用 os.Stdout(保持原有控制台行为)。
Console io.Writer
}
// New 组装日志处理器:控制台保持现状(全量、按 Level 过滤),
// 文件按「放行 / 拦截」两类分流——拦截类固定按 DEBUG 采集,不看 log.level,
// 因为默认级别是 info,跟着过滤就看不到现场要的那份详细拦截日志了。
// 返回的 io.Closer 负责关文件,进程退出前调用。
func New(opts Options) (slog.Handler, io.Closer) {
console := opts.Console
if console == nil {
console = os.Stdout
}
maxBytes := int64(opts.MaxSizeMB) * 1024 * 1024
if maxBytes <= 0 {
maxBytes = int64(DefaultMaxSizeMB) * 1024 * 1024
}
keep := opts.KeepFiles
if keep <= 0 {
keep = DefaultKeepFiles
}
dir := opts.Dir
// 降级告警只发一次:控制台那条链路是现成的,且不会绕回文件处理器造成递归。
consoleLogger := slog.New(slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level}))
var warnOnce sync.Once
warn := func(err error) {
warnOnce.Do(func() {
consoleLogger.Warn("日志文件不可写,已降级为只写控制台", "dir", dir, "err", err)
})
}
ops := newRotator(filepath.Join(dir, DirOperations), operationsPrefix, maxBytes, keep, warn)
inter := newRotator(filepath.Join(dir, DirInterceptions), interceptionPrefix, maxBytes, keep, warn)
// 放行类跟着 log.level 过滤;拦截类固定 Debug 采集,不跟着过滤(现场要的就是那份详细日志)。
return &handler{
console: slog.NewTextHandler(console, &slog.HandlerOptions{Level: opts.Level}),
ops: slog.NewJSONHandler(ops, &slog.HandlerOptions{Level: opts.Level}),
inter: slog.NewJSONHandler(inter, &slog.HandlerOptions{Level: slog.LevelDebug}),
}, closerFunc(func() error { return closerAll(ops, inter) })
}
type handler struct {
console slog.Handler
ops slog.Handler
inter slog.Handler
}
func (h *handler) Enabled(ctx context.Context, level slog.Level) bool {
return h.console.Enabled(ctx, level) || h.ops.Enabled(ctx, level) || h.inter.Enabled(ctx, level)
}
func (h *handler) Handle(ctx context.Context, rec slog.Record) error {
// 控制台照旧:所有类别都打,按配置级别过滤。
if h.console.Enabled(ctx, rec.Level) {
if err := h.console.Handle(ctx, rec); err != nil {
return err
}
}
// 文件按类别分流:拦截类固定 Debug 采集,放行类按配置级别。
if isInterception(rec) {
if h.inter.Enabled(ctx, rec.Level) {
return h.inter.Handle(ctx, rec)
}
return nil
}
if h.ops.Enabled(ctx, rec.Level) {
return h.ops.Handle(ctx, rec)
}
return nil
}
func (h *handler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &handler{
console: h.console.WithAttrs(attrs),
ops: h.ops.WithAttrs(attrs),
inter: h.inter.WithAttrs(attrs),
}
}
func (h *handler) WithGroup(name string) slog.Handler {
return &handler{
console: h.console.WithGroup(name),
ops: h.ops.WithGroup(name),
inter: h.inter.WithGroup(name),
}
}
// isInterception 判断一条日志是不是「我们主动拦截 / 服务端异常」:
// 拦截类日志带类别标记;ERROR 级别的异常即使没标记也归拦截类,它们是排查用的。
func isInterception(rec slog.Record) bool {
if rec.Level >= slog.LevelError {
return true
}
marked := false
rec.Attrs(func(a slog.Attr) bool {
if a.Key == ChannelKey && a.Value.String() == ChannelInterception {
marked = true
return false
}
return true
})
return marked
}
type closerFunc func() error
func (f closerFunc) Close() error { return f() }
func closerAll(cs ...io.Closer) error {
var first error
for _, c := range cs {
if err := c.Close(); err != nil && first == nil {
first = err
}
}
return first
}
+252
View File
@@ -0,0 +1,252 @@
package logging
import (
"bytes"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// newTestLogging 装配一套写进临时目录的日志,返回 logger 与关闭函数。
func newTestLogging(t *testing.T, dir string, level slog.Level, maxSizeMB, keepFiles int) (*slog.Logger, *bytes.Buffer, func()) {
t.Helper()
console := &bytes.Buffer{}
handler, closer := New(Options{
Dir: dir,
Level: level,
MaxSizeMB: maxSizeMB,
KeepFiles: keepFiles,
Console: console,
})
t.Cleanup(func() { _ = closer.Close() })
return slog.New(handler), console, func() { _ = closer.Close() }
}
// readLog 读某类日志今天的文件内容;文件不存在返回空串。
func readLog(t *testing.T, dir, prefix string) string {
t.Helper()
path := filepath.Join(dir, prefix, prefix+"-"+time.Now().Format("20060102")+".log")
raw, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ""
}
if err != nil {
t.Fatalf("读日志 %s: %v", path, err)
}
return string(raw)
}
// 两类分流:放行类进 operations/,拦截类(WARN/上下文/异常)进 interceptions/,互不串门。
func TestRoutesOperationsAndInterceptions(t *testing.T) {
dir := t.TempDir()
logger, console, closeLogs := newTestLogging(t, dir, slog.LevelInfo, 20, 10)
logger.Info("操作已放行", "action", "team.create", "actor", "recorder#1")
logger.Warn("启动告警") // 没带标记的 WARN 属于常规运行日志
logger.Debug("http", "path", "/api/teams") // 默认 info 级别下不落盘
logger.Warn("请求被拦截", "reason", "无权访问该资源", "origin", "internal/middleware/middleware.go:126 RequireRole",
ChannelKey, ChannelInterception)
logger.Debug("拦截上下文", "code", "FORBIDDEN", "origin", "internal/middleware/middleware.go:126 RequireRole",
ChannelKey, ChannelInterception)
logger.Error("请求失败", "code", "INTERNAL", "origin", "internal/httpapi/handlers_results.go:238 displayCommandHandler")
closeLogs()
ops := readLog(t, dir, DirOperations)
inter := readLog(t, dir, DirInterceptions)
for _, want := range []string{"操作已放行", "启动告警", "team.create"} {
if !strings.Contains(ops, want) {
t.Fatalf("operations 应含 %q,得到:\n%s", want, ops)
}
}
for _, unwanted := range []string{"请求被拦截", "拦截上下文", "请求失败", `"msg":"http"`} {
if strings.Contains(ops, unwanted) {
t.Fatalf("operations 不该含 %q,得到:\n%s", unwanted, ops)
}
}
for _, want := range []string{"请求被拦截", "拦截上下文", "请求失败", "RequireRole", "无权访问该资源"} {
if !strings.Contains(inter, want) {
t.Fatalf("interceptions 应含 %q,得到:\n%s", want, inter)
}
}
for _, unwanted := range []string{"操作已放行", "启动告警", `"msg":"http"`} {
if strings.Contains(inter, unwanted) {
t.Fatalf("interceptions 不该含 %q,得到:\n%s", unwanted, inter)
}
}
// 控制台保持现状:全量按级别输出。
if !strings.Contains(console.String(), "操作已放行") || !strings.Contains(console.String(), "请求被拦截") {
t.Fatalf("控制台应同时看到两类日志,得到:\n%s", console.String())
}
}
// 拦截类固定按 DEBUG 采集:把级别压到 error,拦截的 WARN 与上下文 DEBUG 仍要落盘。
func TestInterceptionsIgnoreConfiguredLevel(t *testing.T) {
dir := t.TempDir()
logger, _, closeLogs := newTestLogging(t, dir, slog.LevelError, 20, 10)
logger.Info("操作已放行", "action", "team.create")
logger.Warn("启动告警")
logger.Warn("请求被拦截", "reason", "请先登录", "origin", "internal/middleware/middleware.go:140 RequireWrite",
ChannelKey, ChannelInterception)
logger.Debug("拦截上下文", "code", "AUTH_REQUIRED", ChannelKey, ChannelInterception)
closeLogs()
ops := readLog(t, dir, DirOperations)
inter := readLog(t, dir, DirInterceptions)
if strings.Contains(ops, "操作已放行") || strings.Contains(ops, "启动告警") {
t.Fatalf("级别为 error 时 operations 不该有 INFO/WARN,得到:\n%s", ops)
}
if !strings.Contains(inter, "请求被拦截") || !strings.Contains(inter, "拦截上下文") {
t.Fatalf("拦截类不受 log.level 影响,应仍有 WARN 与 DEBUG,得到:\n%s", inter)
}
}
// 轮转与保留:超过大小上限续写 .1/.2,超出保留份数的最旧文件被清理。
func TestRotatorRotatesAndPrunes(t *testing.T) {
dir := t.TempDir()
w := newRotator(dir, "operations", 120, 3, nil)
defer func() { _ = w.Close() }()
// 每行约 30 字节,写 12 行 → 至少 3 个文件,保留 3 份后仍不该无限涨。
line := []byte("0123456789012345678901234567\n")
for i := 0; i < 12; i++ {
if _, err := w.Write(line); err != nil {
t.Fatalf("写日志: %v", err)
}
}
files := w.list()
names := make([]string, 0, len(files))
for _, f := range files {
names = append(names, f.name)
}
if len(files) != 3 {
t.Fatalf("应轮转出 3 个文件(保留 3 份),得到 %v", names)
}
day := time.Now().Format("20060102")
want := []string{"operations-" + day + ".log", "operations-" + day + ".1.log", "operations-" + day + ".2.log"}
if strings.Join(names, ",") != strings.Join(want, ",") {
t.Fatalf("文件名应为 %v,得到 %v", want, names)
}
// 每个文件都不超过上限(最后一个是当前写入文件,可能刚好装下最后几行)。
for _, f := range files {
info, err := os.Stat(filepath.Join(dir, f.name))
if err != nil {
t.Fatalf("stat %s: %v", f.name, err)
}
if info.Size() > 120 {
t.Fatalf("%s 超过大小上限:%d 字节", f.name, info.Size())
}
}
}
// 序号按数字排序:.10 比 .2 新,清理时不能把新文件当旧的删掉。
func TestRotatorPruneKeepsNewestByIndex(t *testing.T) {
dir := t.TempDir()
day := time.Now().Format("20060102")
for _, name := range []string{
"operations-" + day + ".2.log",
"operations-" + day + ".10.log",
"operations-" + day + ".1.log",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
t.Fatalf("造日志文件: %v", err)
}
}
w := newRotator(dir, "operations", 1024, 2, nil)
w.prune()
left := []string{}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
left = append(left, e.Name())
}
if len(left) != 2 {
t.Fatalf("应保留 2 份,得到 %v", left)
}
// 最旧的是 .1,必须被删掉。
if strings.Contains(strings.Join(left, ","), ".1.log") {
t.Fatalf("最旧的 .1 应被清理,得到 %v", left)
}
}
// 写盘失败只降级、不拖垮服务:控制台仍输出,且告警只发一次。
func TestDegradesToConsoleWhenDirUnwritable(t *testing.T) {
// 用一个普通文件顶替日志目录:MkdirAll 必然失败,且对 root 也成立。
blocked := filepath.Join(t.TempDir(), "logs")
if err := os.WriteFile(blocked, []byte("not a dir"), 0o644); err != nil {
t.Fatalf("造占位文件: %v", err)
}
logger, console, closeLogs := newTestLogging(t, blocked, slog.LevelInfo, 20, 10)
defer closeLogs()
for i := 0; i < 5; i++ {
logger.Warn("请求被拦截", "reason", "无权访问该资源", ChannelKey, ChannelInterception)
}
if got := strings.Count(console.String(), "日志文件不可写,已降级为只写控制台"); got != 1 {
t.Fatalf("降级告警应只发一次,得到 %d 次:\n%s", got, console.String())
}
if got := strings.Count(console.String(), "请求被拦截"); got != 5 {
t.Fatalf("降级后拦截日志仍要打控制台,得到 %d 行:\n%s", got, console.String())
}
// 进程照常:没有任何 panic / 阻塞,logger 调用全部返回。
logger.Info("操作已放行")
}
// 日期变化时开新文件(用可注入的时钟直接调 prepare,避免真等到第二天)。
func TestRotatorStartsNewFileOnNewDay(t *testing.T) {
dir := t.TempDir()
w := newRotator(dir, "operations", 1024, 10, nil)
defer func() { _ = w.Close() }()
if _, err := w.Write([]byte("day1\n")); err != nil {
t.Fatalf("写日志: %v", err)
}
tomorrow := time.Now().Add(24 * time.Hour)
if err := w.prepare(6, tomorrow); err != nil {
t.Fatalf("跨天 prepare: %v", err)
}
if _, err := w.Write([]byte("day2\n")); err != nil {
t.Fatalf("写日志: %v", err)
}
for _, day := range []string{time.Now().Format("20060102"), tomorrow.Format("20060102")} {
path := filepath.Join(dir, "operations-"+day+".log")
if _, err := os.Stat(path); err != nil {
t.Fatalf("跨天后应存在 %s: %v", path, err)
}
}
}
// 拦截点里的函数名要短而准:内联后的闭包名会拖一长串调用链,必须收敛。
func TestOriginFuncNameIsShort(t *testing.T) {
cases := map[string]string{
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware.RequireRole.func1": "RequireRole.func1",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).write.(*server).write.RequireWrite.func1.func4": "RequireWrite.func1",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).createEvent": "(*server).createEvent",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.normalizeAvgMode": "normalizeAvgMode",
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi.(*server).importEntriesWith.func1": "(*server).importEntriesWith.func1",
"main.main": "main",
}
for in, want := range cases {
if got := shortFuncName(in); got != want {
t.Errorf("shortFuncName(%q) = %q,期望 %q", in, got, want)
}
}
// 模块内的路径裁成「internal/...」相对路径,不带开发机目录。
if sourceRoot != "" {
if got := FormatOrigin(sourceRoot+"/internal/httpapi/repo.go", 12, "p.f"); got != "internal/httpapi/repo.go:12 f" {
t.Errorf("FormatOrigin 应裁成相对路径,得到 %q", got)
}
}
// 裁不动的路径只留文件名,同样不泄漏绝对路径。
if got := FormatOrigin("/somewhere/else/repo.go", 12, "p.f"); got != "repo.go:12 f" {
t.Errorf("裁不动时应只留文件名,得到 %q", got)
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package logging 负责服务端日志的落盘:按「放行 / 拦截」两类分流到不同目录,
// 各自按天 + 按大小轮转并保留固定份数,写盘失败时降级为只写控制台。
//
// 单独成包是为了让 httpapi 与 middleware 共用同一份「拦截点」捕获与类别标记,
// 又不会互相依赖(两边都只依赖本包)。
package logging
import (
"fmt"
"path/filepath"
"runtime"
"strings"
)
// 类别标记:日志按「是不是我们主动拦截的」分流,而不是只看级别——
// DEBUG 里既有常规访问日志,也有拦截上下文,只按级别分不开。
const (
// ChannelKey 是类别标记的属性名;拦截类日志都带上它。
ChannelKey = "log.channel"
// ChannelInterception 是拦截类的取值。
ChannelInterception = "interception"
)
// sourceRoot 是本包所在模块的根目录(…/server);用它把编译期路径裁成
// 「internal/httpapi/xxx.go」这样的仓库内相对路径,日志里不出现开发机的目录结构。
// 用 -trimpath 构建时 file 本来就是相对路径,裁剪会自然退化成原样。
var sourceRoot = func() string {
_, file, _, ok := runtime.Caller(0)
if !ok {
return ""
}
// 本文件位于 <root>/internal/logging/origin.go,向上三级即 <root>。
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}()
// relativeSourcePath 把编译期文件路径裁成仓库内相对路径;裁不动时只留文件名。
func relativeSourcePath(file string) string {
if sourceRoot != "" {
if rel := strings.TrimPrefix(file, sourceRoot+string(filepath.Separator)); rel != file {
return rel
}
}
return filepath.Base(file)
}
// shortFuncName 尽量给出「(*server).createEvent」「RequireWrite」这样的短名。
// 编译器会把闭包按内联链写成 (*server).write.(*server).write.RequireWrite.func1.func4,
// 直接取最后一段会得到一串噪音,这里只保留「有意义的那个函数名 + 第一个闭包序号」。
func shortFuncName(full string) string {
if i := strings.LastIndex(full, "/"); i >= 0 {
full = full[i+1:]
}
rest := full
if i := strings.Index(rest, "."); i >= 0 {
rest = rest[i+1:]
}
parts := strings.Split(rest, ".")
if len(parts) == 1 {
return rest
}
// 跳过尾部的闭包序号,落回真正定义它的那个函数。
owner := len(parts) - 1
for owner > 0 && strings.HasPrefix(parts[owner], "func") {
owner--
}
name := parts[owner]
// 闭包序号只留第一个:够区分同一个函数里的多个闭包。
if owner+1 < len(parts) && strings.HasPrefix(parts[owner+1], "func") {
name += "." + parts[owner+1]
}
// 方法带上接收者,「(*server).createEvent」比「createEvent」好找。
if owner > 0 && strings.HasPrefix(parts[owner-1], "(*") {
name = parts[owner-1] + "." + name
}
return name
}
// FormatOrigin 拼出「internal/middleware/middleware.go:123 RequireRole」。
func FormatOrigin(file string, line int, fn string) string {
return fmt.Sprintf("%s:%d %s", relativeSourcePath(file), line, shortFuncName(fn))
}
// Origin 捕获调用点(skip 从本函数自己那层算起),产出「文件:行 函数名」。
// 日志里必须能指出是哪段代码拦下的,否则现场只有一句中文提示,定位不到校验代码。
func Origin(skip int) string {
pc, file, line, ok := runtime.Caller(skip)
if !ok {
return "unknown"
}
fn := "unknown"
if f := runtime.FuncForPC(pc); f != nil {
fn = f.Name()
}
return FormatOrigin(file, line, fn)
}
+206
View File
@@ -0,0 +1,206 @@
package logging
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// rotator 是「按天 + 按大小」轮转的文件写入器,并按份数清理旧文件。
//
// 现场要跑一整天,日志既不能无限涨到把盘写满,也不能因为写不进去就影响业务:
// 一旦写盘失败就永久降级(只通知一次),后续日志由控制台那条链路兜住。
type rotator struct {
dir string
prefix string
maxBytes int64
keep int
// onError 只在第一次写盘失败时回调一次:每条日志都刷告警会把控制台淹掉。
onError func(error)
mu sync.Mutex
file *os.File
size int64
curName string
curDay string
curIdx int
broken bool
}
func newRotator(dir, prefix string, maxBytes int64, keep int, onError func(error)) *rotator {
return &rotator{dir: dir, prefix: prefix, maxBytes: maxBytes, keep: keep, onError: onError}
}
// Write 永不返回错误:日志写不进去是运维问题,不能变成业务请求失败。
func (w *rotator) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.broken {
return len(p), nil
}
if err := w.prepare(len(p), time.Now()); err != nil {
w.degrade(err)
return len(p), nil
}
n, err := w.file.Write(p)
w.size += int64(n)
if err != nil {
w.degrade(err)
}
return len(p), nil
}
// Close 关掉当前文件;进程退出前调用,保证缓冲里的内容不会丢。
func (w *rotator) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotator) degrade(err error) {
w.broken = true
if w.file != nil {
_ = w.file.Close()
w.file = nil
}
if w.onError != nil {
w.onError(err)
}
}
// prepare 保证当前打开的是「今天该写、且还装得下」的那个文件。
func (w *rotator) prepare(need int, now time.Time) error {
day := now.Format("20060102")
if w.file == nil || day != w.curDay {
if err := w.closeLocked(); err != nil {
return err
}
w.curDay = day
w.curIdx = 0
return w.openLocked()
}
// 当前文件已经装了东西且放不下这一条时才轮转:空文件永远装得下,
// 否则超长单条会把每个新文件立刻撑爆、无限轮转。
if w.size > 0 && w.size+int64(need) > w.maxBytes {
if err := w.closeLocked(); err != nil {
return err
}
w.curIdx++
return w.openLocked()
}
return nil
}
func (w *rotator) closeLocked() error {
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotator) openLocked() error {
if err := os.MkdirAll(w.dir, 0o755); err != nil {
return err
}
name := w.fileName(w.curDay, w.curIdx)
f, err := os.OpenFile(filepath.Join(w.dir, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
_ = f.Close()
return err
}
w.file = f
w.size = info.Size()
w.curName = name
w.prune()
return nil
}
// fileName 是「<类别>-<YYYYMMDD>.log」,同一天的第 N 个续写文件加序号:
// operations-20260911.log / operations-20260911.1.log / .2.log…
func (w *rotator) fileName(day string, idx int) string {
if idx <= 0 {
return fmt.Sprintf("%s-%s.log", w.prefix, day)
}
return fmt.Sprintf("%s-%s.%d.log", w.prefix, day, idx)
}
// prune 清掉超出保留份数的最旧文件;调用方必须持有锁。
// 轮转与开新文件时都会走一遍,保留策略才有执行点(只在启动时清会一直涨)。
func (w *rotator) prune() {
if w.keep <= 0 {
return
}
files := w.list()
if len(files) <= w.keep {
return
}
for _, f := range files[:len(files)-w.keep] {
if f.name == w.curName {
continue
}
_ = os.Remove(filepath.Join(w.dir, f.name))
}
}
type logFile struct {
name string
day string
index int
}
// list 列出本类别的日志文件,按「日期 + 序号」从旧到新排序。
// 只认自己写出来的命名,别人的文件一律不碰。
func (w *rotator) list() []logFile {
entries, err := os.ReadDir(w.dir)
if err != nil {
return nil
}
prefix := w.prefix + "-"
out := make([]logFile, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".log") {
continue
}
rest := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".log")
day, idxText, _ := strings.Cut(rest, ".")
if len(day) != 8 {
continue
}
idx := 0
if idxText != "" {
n, err := strconv.Atoi(idxText)
if err != nil {
continue
}
idx = n
}
out = append(out, logFile{name: name, day: day, index: idx})
}
sort.Slice(out, func(i, j int) bool {
if out[i].day != out[j].day {
return out[i].day < out[j].day
}
return out[i].index < out[j].index
})
return out
}
+23
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
@@ -88,7 +89,29 @@ func (a *Auditor) flush(ctx context.Context, r *http.Request, entries []AuditEnt
if err != nil {
// 审计失败不能吞掉业务结果,但必须留下痕迹。
a.log.Error("写入审计失败", "err", err, "action", e.Action, "requestId", requestID)
continue
}
// 落库成功即「这次操作被放行」:默认 info 级别下这是唯一的放行痕迹,
// 与拦截的 WARN 成对,现场才能对上「谁在什么时候做了什么」。
a.log.Info("操作已放行",
"action", e.Action,
"entity", e.EntityType,
"entityId", e.EntityID,
"actor", auditActorLabel(u, e.ActorID),
"requestId", requestID)
}
}
// auditActorLabel 是放行日志里的操作人:登录请求的身份来自条目自带的 ActorID,
// 其余来自会话;两边都拿不到就是 guest。记「用户名#id」比裸 id 好认。
func auditActorLabel(u *auth.User, actorID int64) string {
switch {
case actorID != 0:
return fmt.Sprintf("user#%d", actorID)
case u != nil && u.ID != 0:
return fmt.Sprintf("%s#%d", u.Username, u.ID)
default:
return "guest"
}
}
+53 -12
View File
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
@@ -16,6 +17,7 @@ import (
"time"
"git.yeij.top/AskaEth/Scoreboard/server/internal/auth"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
)
// HeaderRequestID 是客户端可复用的幂等/追踪请求头。
@@ -123,11 +125,12 @@ func Authenticate(svc *auth.Service, logger *slog.Logger) func(http.Handler) htt
// RequireRole 要求至少 need 权限;未登录或权限不足一律 403。
// 用于「guest 也不该看」的只读接口(系统配置),以便与未登录写操作区分。
func RequireRole(need auth.Role) func(http.Handler) http.Handler {
func RequireRole(logger *slog.Logger, need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !User(r.Context()).Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权访问该资源", map[string]any{"required": need.String()})
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权访问该资源",
map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
@@ -136,16 +139,17 @@ func RequireRole(need auth.Role) func(http.Handler) http.Handler {
}
// RequireWrite 要求至少 need 权限的写操作;guest 返回 401(需登录),角色不足返回 403。
func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
func RequireWrite(logger *slog.Logger, need auth.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest {
writeError(w, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
intercept(logger, w, r, http.StatusUnauthorized, "AUTH_REQUIRED", "请先登录", nil)
return
}
if !u.Role.Allows(need) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "无权执行该操作", map[string]any{"required": need.String()})
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "无权执行该操作",
map[string]any{"required": need.String()})
return
}
next.ServeHTTP(w, r)
@@ -155,7 +159,7 @@ func RequireWrite(need auth.Role) func(http.Handler) http.Handler {
// MustChangePassword 阻止「必须修改密码」的账号执行写操作。
// 只放行改密与登出:否则用户会被永久锁在无法改密的死循环里(见 devdocs/08 第 1 节)。
func MustChangePassword(next http.Handler) http.Handler {
func MustChangePassword(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u := User(r.Context())
if u.Role == auth.RoleGuest || !u.MustChangePassword {
@@ -172,12 +176,12 @@ func MustChangePassword(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
writeError(w, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil)
intercept(logger, w, r, http.StatusForbidden, "AUTH_REQUIRED", "请先修改初始密码", nil)
})
}
// CSRF 校验双提交 Cookie:已登录的写请求必须带 X-CSRF-Token 且与 csrf_token Cookie 一致。
func CSRF(next http.Handler) http.Handler {
func CSRF(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
@@ -187,7 +191,7 @@ func CSRF(next http.Handler) http.Handler {
// 登录是 guest 请求,拿不到 CSRF Token;跨站表单仍能把受害者登进攻击者的账号,
// 因此对带 Origin/Referer 的登录请求单独做同源校验。
if r.Method == http.MethodPost && r.URL.Path == "/api/auth/login" && !sameOrigin(r) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败", nil)
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "登录请求来源校验失败,请刷新页面重试", nil)
return
}
if User(r.Context()).Role == auth.RoleGuest {
@@ -200,7 +204,7 @@ func CSRF(next http.Handler) http.Handler {
cookie = c.Value
}
if !auth.ValidCSRF(r.Header.Get("X-CSRF-Token"), cookie) {
writeError(w, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败", nil)
intercept(logger, w, r, http.StatusForbidden, "FORBIDDEN", "CSRF 校验失败,请刷新页面重试", nil)
return
}
next.ServeHTTP(w, r)
@@ -225,7 +229,7 @@ func sameOrigin(r *http.Request) bool {
}
// RateLimitWrite 对已登录用户的写操作限流(默认 30 次/分钟/用户)。
func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
func RateLimitWrite(svc *auth.Service, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
@@ -235,7 +239,7 @@ func RateLimitWrite(svc *auth.Service) func(http.Handler) http.Handler {
}
u := User(r.Context())
if u.Role != auth.RoleGuest && !svc.AllowWrite(u.ID) {
writeError(w, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
intercept(logger, w, r, http.StatusTooManyRequests, "RATE_LIMITED", "操作过于频繁,请稍后再试", nil)
return
}
next.ServeHTTP(w, r)
@@ -283,6 +287,43 @@ func (w *statusWriter) Flush() {
}
// writeError 输出统一错误结构;中间件不依赖 httpapi,避免包循环。
// intercept 记录一次中间件层的拦截,再写出统一响应。
// 这几个中间件都在 handler 之前就返回响应,不经 httpapi 的 writeAPIError——
// 不在这里记的话,这些拦截完全没有痕迹。收成一个函数是为了「只要写了响应就一定有日志」。
// 与 httpapi 的拦截日志同一形状:WARN 给原因与拦截点,DEBUG 补上下文,两类都进 interceptions/。
func intercept(logger *slog.Logger, w http.ResponseWriter, r *http.Request, status int, code, message string, detail any) {
if logger == nil {
logger = slog.Default()
}
u := User(r.Context())
actor := actorLabel(u)
role := u.Role.String()
reqID := GetRequestID(r.Context())
// skip=2:0 是本包 Origin 自己的帧,1 是 intercept,2 才是做校验的那个中间件函数。
origin := logging.Origin(2)
logger.Warn("请求被拦截",
"reason", message, "origin", origin, "code", code,
"method", r.Method, "path", r.URL.Path,
"actor", actor, "role", role, "requestId", reqID, "ip", ClientIP(r),
logging.ChannelKey, logging.ChannelInterception)
logger.Debug("拦截上下文",
"code", code, "detail", detail, "origin", origin,
"method", r.Method, "path", r.URL.Path, "ip", ClientIP(r),
"actor", actor, "role", role, "requestId", reqID,
logging.ChannelKey, logging.ChannelInterception)
writeError(w, status, code, message, detail)
}
// actorLabel 是日志里的操作人:未登录记 guest,登录后记「用户名#id」,比裸 id 好认。
func actorLabel(u *auth.User) string {
if u == nil || u.ID == 0 {
return "guest"
}
return fmt.Sprintf("%s#%d", u.Username, u.ID)
}
func writeError(w http.ResponseWriter, status int, code, message string, detail any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
@@ -51,7 +51,7 @@ func TestRequestIDKeepsNormalHeader(t *testing.T) {
// 登录是 guest 请求,CSRF Token 校验被跳过,靠同源校验挡住 login CSRF(P2-6)。
func TestCSRFBlocksCrossOriginLogin(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
h := CSRF(next)
h := CSRF(nil, next)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
req.Host = "scoreboard.local"
+12 -1
View File
@@ -22,6 +22,7 @@ import (
"git.yeij.top/AskaEth/Scoreboard/server/internal/config"
"git.yeij.top/AskaEth/Scoreboard/server/internal/export"
"git.yeij.top/AskaEth/Scoreboard/server/internal/httpapi"
"git.yeij.top/AskaEth/Scoreboard/server/internal/logging"
"git.yeij.top/AskaEth/Scoreboard/server/internal/middleware"
"git.yeij.top/AskaEth/Scoreboard/server/internal/realtime"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
@@ -63,7 +64,17 @@ func main() {
// 不传(或传空串)时保持配置文件里的相对 data,便携与手工运行的行为不变。
dataDirFromFlag := cfg.ApplyDataDir(*dataDir)
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.Log.SlogLevel()}))
// 控制台输出保持原样;文件按「放行 / 拦截」两类分流落盘(见 internal/logging)。
// 日志写不进去也不能影响服务:装配阶段不返回错误,运行期由 logging 降级为只写控制台。
logHandler, closeLogs := logging.New(logging.Options{
Dir: cfg.Log.DirPath(cfg.DataDir),
Level: cfg.Log.SlogLevel(),
MaxSizeMB: cfg.Log.MaxSizeMB,
KeepFiles: cfg.Log.KeepFiles,
})
defer func() { _ = closeLogs.Close() }()
logger := slog.New(logHandler)
slog.SetDefault(logger)
if dataDirFromFlag {
logger.Info("数据目录来自命令行 -data", "dataDir", cfg.DataDir)
+140 -29
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import {
eventInputOf,
formatPoints,
@@ -17,6 +17,12 @@ import MxIcon from '../components/MxIcon.vue'
import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode, type AvgModeState } from './avgMode'
import {
DISCARD_CONFIRM_MESSAGE,
DISCARD_CONFIRM_TITLE,
createLeaveGuard,
resultCellIssue,
} from './entryGuards'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow } from '../api/scoring'
import { entryRowLabel } from '../components/baseData/entrySelection'
@@ -67,6 +73,7 @@ const STATUS_OPTIONS: Array<{ value: ResultStatus; label: string }> = [
]
const route = useRoute()
const router = useRouter()
const data = useEventStore()
const auth = useAuthStore()
const toast = useToastStore()
@@ -84,6 +91,10 @@ const unpublishDialog = ref(false)
const focusedEntryId = ref<number | null>(null)
const tableRef = ref<HTMLElement | null>(null)
const recordForEvent = ref<{ resultText: string; resultValue: number } | null>(null)
/** 未保存修改的确认框:切换项目与离开页面共用同一个,风格与发布确认一致。 */
const discardDialog = ref(false)
/** 用户确认丢弃后要执行的动作(切项目 / 重新跳转)。 */
let pendingDiscard: (() => void) | null = null
/** 项目切换的请求序号:只有最新一次请求的响应才允许写入页面状态。 */
let requestSeq = 0
@@ -116,6 +127,25 @@ function unitOf(type: Event['resultType']): string {
const unit = computed(() => unitOf(selectedEvent.value?.resultType ?? 'none'))
/**
* 成绩格的示例文本。
*
* 必须带「例」字:光秃秃一个数字会被当成已填内容——用户实机就是这么误判成「填了 1:06」、
* 点发布才知道那一格其实是空的。
*/
const RESULT_SAMPLE: Record<string, string> = {
s: '1:06.33',
m: '12.34',
次: '12',
pts: '96.5',
}
const resultPlaceholder = computed(() => {
const sample = RESULT_SAMPLE[unit.value]
// resultType 为 none 时没有单位,给一个中性的数字例子,不留空白示例。
return sample === undefined ? '例 12' : `例 ${sample}`
})
const visibleRows = computed(() =>
allRows.value.filter((row) => selectedCategoryId.value === null || row.categoryId === selectedCategoryId.value),
)
@@ -177,6 +207,46 @@ const affectedTeams = computed(() => preview.value.filter((row) => row.delta !==
const isPublished = computed(() => visibleRows.value.some((row) => row.resultState === 'published'))
/** dirty 必须扫描全部组别,否则切到另一组别后「有未保存修改」徽标会凭空消失。 */
const dirty = computed(() => allRows.value.some((row) => rowDirty(row)))
const dirtyCount = computed(() => allRows.value.filter((row) => rowDirty(row)).length)
/** 路由离开守卫:左侧菜单切页 / 浏览器前进后退都走这里,未保存的成绩不能静默丢失。 */
const leaveGuard = createLeaveGuard(() => dirty.value)
/** 有未保存修改时先弹确认框,确认后才执行动作(切换项目 / 离开页面共用)。 */
function guardDirty(action: () => void): void {
if (!dirty.value) {
action()
return
}
pendingDiscard = action
discardDialog.value = true
}
function confirmDiscard(): void {
const action = pendingDiscard
pendingDiscard = null
discardDialog.value = false
action?.()
}
function cancelDiscard(): void {
pendingDiscard = null
discardDialog.value = false
leaveGuard.cancel()
}
onBeforeRouteLeave((to) => {
if (leaveGuard.beforeLeave(to.fullPath)) return true
// 已拦下:等用户在这唯一一个确认框里做选择,确认后重放这次跳转。
pendingDiscard = () => {
const target = leaveGuard.confirm()
if (target === null) return
// 跳转结束后收回「已放行」:万一这次跳转被别处拦下,下次离开仍要重新确认。
void router.push(target).finally(() => leaveGuard.reset())
}
discardDialog.value = true
return false
})
/** 已发布的行由服务端锁定:只能先撤销发布,不能就地改草稿。 */
function rowLocked(row: EditableRow): boolean {
@@ -189,24 +259,22 @@ const issues = computed<Issue[]>(() => {
if (event === null) return list
const seenRanks = new Map<number, number>()
for (const row of visibleRows.value) {
for (const [index, row] of visibleRows.value.entries()) {
if (row.status !== 'ok') continue
const needsRank = manualRank.value
if (row.resultText.trim() === '') {
const label = issueLabel(row, index)
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
if (!(needsRank && row.rank !== null)) {
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少成绩` })
}
} else {
const value = parseResultValue(row.resultText, unit.value)
if (value === null) {
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:成绩格式无法解析` })
} else if (value <= 0) {
list.push({ entryId: row.entryId, level: 'warn', message: `${rowLabel(row)}:成绩 ≤ 0,疑似录入错误` })
}
const emptyAllowed = row.resultText.trim() === '' && needsRank && row.rank !== null
const value = row.resultText.trim() === '' ? null : parseResultValue(row.resultText, unit.value)
// 文案由纯函数生成:必须带出那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
const cellIssue = emptyAllowed ? null : resultCellIssue(label, row.resultText, unit.value)
if (cellIssue !== null) {
list.push({ entryId: row.entryId, level: 'error', message: cellIssue.message })
} else if (value !== null && value <= 0) {
list.push({ entryId: row.entryId, level: 'warn', message: `${label}:成绩 ≤ 0,疑似录入错误` })
}
if (needsRank && row.rank === null) {
list.push({ entryId: row.entryId, level: 'error', message: `${rowLabel(row)}:缺少名次` })
list.push({ entryId: row.entryId, level: 'error', message: `${label}:缺少名次` })
}
if (needsRank && row.rank !== null) {
seenRanks.set(row.rank, (seenRanks.get(row.rank) ?? 0) + 1)
@@ -221,7 +289,7 @@ const issues = computed<Issue[]>(() => {
const record = recordForEvent.value
if (record !== null && record.resultValue > 0) {
for (const row of visibleRows.value) {
for (const [index, row] of visibleRows.value.entries()) {
const value = parseResultValue(row.resultText, unit.value)
if (value === null || row.status !== 'ok') continue
const ratio = value / record.resultValue
@@ -229,7 +297,7 @@ const issues = computed<Issue[]>(() => {
list.push({
entryId: row.entryId,
level: 'warn',
message: `${rowLabel(row)}:${row.resultText} 与赛会纪录 ${record.resultText} 差距过大,疑似录入错误`,
message: `${issueLabel(row, index)}:${row.resultText} 与赛会纪录 ${record.resultText} 差距过大,疑似录入错误`,
})
}
}
@@ -246,6 +314,12 @@ function rowLabel(row: EditableRow): string {
return entryRowLabel(row)
}
/** 报错文案里的行号:与当前页签下看到的表格顺序一致,空行名也要有可定位的占位。 */
function issueLabel(row: EditableRow, index: number): string {
const name = rowLabel(row)
return `第 ${index + 1} 行 ${name === '' ? '(未署名)' : name}`
}
function round(value: number): number {
return Number(value.toFixed(2))
}
@@ -306,15 +380,14 @@ function issueFor(entryId: number): Issue | undefined {
return issues.value.find((issue) => issue.entryId === entryId)
}
/** 切换项目会丢弃当前编辑,先让操作员确认。 */
function confirmDiscard(): boolean {
if (!dirty.value) return true
return window.confirm('当前有未保存的成绩修改,切换项目会丢弃这些修改。确定继续吗?')
/** 切换项目会丢弃当前编辑,先让操作员确认;确认框由 guardDirty 负责。 */
function selectEvent(id: number): void {
if (selectedEventId.value === id && detail.value !== null) return
guardDirty(() => void loadEvent(id))
}
async function selectEvent(id: number): Promise<void> {
if (selectedEventId.value === id && detail.value !== null) return
if (!confirmDiscard()) return
/** 真正加载项目:只在确认框放行后调用,加载失败时保持当前项目不变。 */
async function loadEvent(id: number): Promise<void> {
selectedEventId.value = id
const seq = (requestSeq += 1)
loading.value = true
@@ -594,11 +667,11 @@ async function pickInitialEvent(): Promise<void> {
const query = route.query.eventId
const fromQuery = typeof query === 'string' ? Number(query) : Number.NaN
if (Number.isFinite(fromQuery) && data.eventById(fromQuery) !== undefined) {
await selectEvent(fromQuery)
selectEvent(fromQuery)
return
}
const fallback = data.events.find((event) => event.status === 'pending') ?? data.events[0]
if (fallback !== undefined) await selectEvent(fallback.id)
if (fallback !== undefined) selectEvent(fallback.id)
}
onMounted(() => {
@@ -776,15 +849,15 @@ watch(
<span class="team-cell">{{ row.teamShort || row.teamName }}</span>
</td>
<td>{{ row.athleteName || row.memberNames?.join('、') || '—' }}</td>
<td>
<td class="result-cell">
<input
v-model="row.resultText"
data-cell
class="mx-cell-input mono"
class="mx-cell-input mono result-input"
:class="{ 'is-invalid': issueFor(row.entryId)?.level === 'error' }"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
:placeholder="unit === 's' ? '1:06.33' : unit"
:placeholder="resultPlaceholder"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
@@ -836,7 +909,7 @@ watch(
class="mx-cell-input"
:disabled="!canWrite || rowLocked(row)"
:title="rowLocked(row) ? '已发布,请先撤销发布' : ''"
placeholder="—"
placeholder="可留空"
@focus="focusedEntryId = row.entryId"
@blur="focusedEntryId = null"
@keydown="onCellKeydown($event, $event.currentTarget as HTMLElement)"
@@ -928,6 +1001,21 @@ watch(
@confirm="confirmUnpublish"
@cancel="unpublishDialog = false"
/>
<ConfirmDialog
:open="discardDialog"
tone="warning"
:title="DISCARD_CONFIRM_TITLE"
:message="DISCARD_CONFIRM_MESSAGE"
:details="[
`未保存的行:${dirtyCount} 行`,
'如需保留,请先点「存草稿」(Ctrl+Enter),再切换项目或离开本页',
]"
confirm-text="丢弃并继续"
cancel-text="留在本页"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</div>
</template>
@@ -1081,6 +1169,29 @@ watch(
.center {
text-align: center;
}
/*
* 成绩格必须放得下 `1:06.33`:表格是自动布局,输入框 width:100% 对列宽没有贡献,
* 这一列会被挤到连 4 位数字都看不见。
* 12px 等宽字体下实测 `1:06.33` 最宽约 50.6px(DejaVu Sans Mono),加 20px 左右内边距
* 与 2px 边框约需 73px;取 112px 留 ~1.5 倍余量,可容纳 12 个等宽字符。
* 输入框与单元格各写一份 min-width:前者保证输入本身够宽,后者保证列被撑开
* (自动布局下有些浏览器只认其中一边)。140 = 112 + 12/16px 左右内边距。
* 单位 / 运动员列是 nowrap,最小宽度天然等于文字宽度,不会被这一列压没。
*/
.result-input {
min-width: 112px;
}
.result-cell {
min-width: 140px;
}
/*
* 示例文本要一眼看出不是已填内容:更淡(--mx-t3)+ 斜体。
* 只挂在录入表格范围内,不动全局 .mx-cell-input——基础数据等页面也在用它。
*/
.mx-table-entry .mx-cell-input::placeholder {
color: var(--mx-t3);
font-style: italic;
}
.rank-input {
width: 72px;
text-align: center;
+9 -9
View File
@@ -3,7 +3,6 @@ import { computed, onMounted, ref, watch } from 'vue'
import {
ApiError,
formatPoints,
parseResultValue,
type EntryWithResult,
type Event,
type EventCategory,
@@ -14,6 +13,7 @@ import PageHeader from '../components/PageHeader.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import { adminApi } from '../api/client'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
import { resultCellIssue } from './entryGuards'
import { useAuthStore } from '../stores/auth'
import { useEventStore } from '../stores/event'
import { useToastStore } from '../stores/toast'
@@ -146,19 +146,19 @@ function unitOf(type: Event['resultType']): string {
return ''
}
/** 发布前防呆:与录入页同一套校验,避免整组发布缺成绩 / 无法解析的行。 */
/** 发布前防呆:与录入页共用同一套文案,避免整组发布缺成绩 / 无法解析的行。 */
function publishBlockers(group: DraftGroup): string[] {
const unit = unitOf(group.event.resultType)
const manual = group.event.resultMode === 'manual' || group.event.compare === 'none'
const blockers: string[] = []
for (const row of group.rows) {
for (const [index, row] of group.rows.entries()) {
if (row.status !== 'ok') continue
const label = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
if (row.resultText.trim() === '') {
if (!(manual && row.rank !== null)) blockers.push(`${label}:缺少成绩`)
} else if (parseResultValue(row.resultText, unit) === null) {
blockers.push(`${label}:成绩格式无法解析`)
}
const name = row.athleteName || row.memberNames?.join('、') || row.teamShort || `#${row.entryId}`
const label = `第 ${index + 1} 行 ${name}`
// 手动录名次的项目(如篮球)允许只填名次、不填成绩文本。
const emptyAllowed = row.resultText.trim() === '' && manual && row.rank !== null
const issue = emptyAllowed ? null : resultCellIssue(label, row.resultText, unit)
if (issue !== null) blockers.push(issue.message)
if (manual && row.rank === null) blockers.push(`${label}:缺少名次`)
}
return blockers
+110
View File
@@ -0,0 +1,110 @@
import { parseResultValue, parseSecondsDetail } from '@/shared'
/**
* 成绩录入 / 发布前的防呆守卫(纯函数)。
*
* 抽出来的原因:`.vue` 组件跑不进 `node --test`,而这里恰好是用户踩过的两个坑——
* 一是成绩格的报错必须自证「那一格里到底有什么」,二是未保存的成绩不能静默丢失。
*/
// ── 成绩格报错文案 ──────────────────────────────────────────────────────────
export type ResultCellIssueKind = 'empty' | 'seconds-overflow' | 'unrecognized'
export interface ResultCellIssue {
kind: ResultCellIssueKind
/** 可直接展示的报错文案。 */
message: string
}
/** 那一格的实际内容:空值也必须显形,用户才能确认「我填的东西到底进没进这一格」。 */
function cellText(text: string): string {
const trimmed = text.trim()
return trimmed === '' ? '(空)' : trimmed
}
/** 无法识别时的可填写法提示,按项目单位区分,避免拿时间格式去教距离项目。 */
function formatHint(unit: string): string {
if (unit === 's') return '可用 66、66.33、1:06、1:06.33、1分06秒'
if (unit === 'm') return '可用 12.34 这类数字(单位:米)'
if (unit === '次') return '可用 12 这类整数(单位:次)'
if (unit === 'pts') return '可用 12 或 12.5 这类数字(单位:分)'
return '请填写数字'
}
/**
* 一个成绩格是否阻止发布;通过时返回 null。
*
* 三种失败原因的文案必须互不混淆,且都带上那一格的实际内容:
* 「空的」和「填错格子」看起来一样,只写「缺少成绩」会把人引去改本来就对的格式。
*/
export function resultCellIssue(label: string, text: string, unit: string): ResultCellIssue | null {
if (text.trim() === '') {
return { kind: 'empty', message: `${label}:成绩为空(当前「${cellText(text)}」),请填写成绩` }
}
if (parseResultValue(text, unit) !== null) return null
if (unit === 's' && parseSecondsDetail(text).failure === 'seconds-overflow') {
return {
kind: 'seconds-overflow',
message: `${label}:秒位不能 ≥ 60(当前「${cellText(text)}」),请写成 1:06 或 66 这类`,
}
}
return {
kind: 'unrecognized',
message: `${label}:成绩写法无法识别(当前「${cellText(text)}」),${formatHint(unit)}`,
}
}
// ── 未保存修改的离开守卫 ────────────────────────────────────────────────────
export const DISCARD_CONFIRM_TITLE = '有未保存的成绩修改'
export const DISCARD_CONFIRM_MESSAGE = '未保存的成绩修改会被丢弃,且无法恢复。'
export interface LeaveGuard {
/** onBeforeRouteLeave 的判定:true 放行;false 表示已拦下,等用户确认。 */
beforeLeave(target: string): boolean
/** 用户确认离开时调用:返回需要重新跳转的目标,null 表示没有待确认的离开。 */
confirm(): string | null
/** 用户选择留在本页。 */
cancel(): void
/** 重新跳转结束后调用,清除「已放行」;跳转被别处拦下时不能让下次离开静默通过。 */
reset(): void
}
/**
* 路由离开守卫的状态机。
*
* onBeforeRouteLeave 不能 await 一个确认框,所以拆成「先拦下 → 用户确认 → 重新跳转放行」:
* 拦下时记住目标地址,确认后由页面用 router.push 重放,重放那一次必须放行,
* 否则守卫会把自己拦下来变成死循环。
*/
export function createLeaveGuard(isDirty: () => boolean): LeaveGuard {
let pending: string | null = null
let released = false
return {
beforeLeave(target) {
if (released) {
released = false
return true
}
if (!isDirty()) return true
pending = target
return false
},
confirm() {
const target = pending
pending = null
if (target === null) return null
released = true
return target
},
cancel() {
pending = null
},
reset() {
pending = null
released = false
},
}
}
+89
View File
@@ -0,0 +1,89 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createLeaveGuard, resultCellIssue } from '../pages/entryGuards'
// 用户实机反馈:只写「缺少成绩」会让人去改本来就对的成绩格式,
// 文案必须带上那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
test('空格子:文案说明成绩为空并带出「(空)」', () => {
const issue = resultCellIssue('第 3 行 张三', ' ', 's')
assert.equal(issue?.kind, 'empty')
assert.equal(issue?.message, '第 3 行 张三:成绩为空(当前「(空)」),请填写成绩')
})
test('秒位 ≥ 60:文案带出原内容和可用的写法', () => {
const issue = resultCellIssue('第 3 行 张三', '1:70', 's')
assert.equal(issue?.kind, 'seconds-overflow')
assert.equal(issue?.message, '第 3 行 张三:秒位不能 ≥ 60(当前「1:70」),请写成 1:06 或 66 这类')
})
test('写法不认识:文案带出原内容和可填写法', () => {
const issue = resultCellIssue('第 3 行 张三', '约1分06秒', 's')
assert.equal(issue?.kind, 'unrecognized')
assert.equal(
issue?.message,
'第 3 行 张三:成绩写法无法识别(当前「约1分06秒」),可用 66、66.33、1:06、1:06.33、1分06秒',
)
})
test('三种失败原因的文案互不相同,且都能定位到那一格', () => {
const empty = resultCellIssue('第 3 行 张三', '', 's')
const overflow = resultCellIssue('第 3 行 张三', '1:70', 's')
const unknown = resultCellIssue('第 3 行 张三', '快点跑', 's')
const messages = [empty, overflow, unknown].map((issue) => issue?.message ?? '')
assert.equal(new Set(messages).size, 3)
for (const message of messages) {
assert.ok(message.startsWith('第 3 行 张三:'), message)
}
assert.ok(messages[1]?.includes('「1:70」'))
assert.ok(messages[2]?.includes('「快点跑」'))
assert.ok(messages[0]?.includes('「(空)」'))
})
test('容错后的写法不再报错', () => {
for (const text of ['1:06', '1:06', '1分06秒', '1分6秒', '1分06', '66', '66.33']) {
assert.equal(resultCellIssue('第 1 行 李四', text, 's'), null, text)
}
})
test('距离 / 计数项目的文案不套用时间格式提示', () => {
const issue = resultCellIssue('第 2 行 王五', '1.2.3', 'm')
assert.equal(issue?.kind, 'unrecognized')
assert.ok(issue?.message.includes('(当前「1.2.3」)'))
assert.ok(issue?.message.includes('米'))
assert.ok(!issue?.message.includes('1:06'), '不该拿时间格式去教距离项目')
})
// 路由离开守卫:dirty 为 true 时必须拦下,确认后重放那一次必须放行(否则死循环)。
test('未保存时离开被拦下,确认后重放放行', () => {
let dirty = true
const guard = createLeaveGuard(() => dirty)
assert.equal(guard.beforeLeave('/dashboard'), false)
assert.equal(guard.confirm(), '/dashboard')
assert.equal(guard.beforeLeave('/dashboard'), true)
// 用户取消后仍然拦下。
assert.equal(guard.beforeLeave('/review'), false)
guard.cancel()
assert.equal(guard.confirm(), null)
assert.equal(guard.beforeLeave('/review'), false)
// 保存后就不再拦。
dirty = false
assert.equal(guard.beforeLeave('/review'), true)
})
test('重放没走到守卫时,重新跳转结束仍要重新确认', () => {
const guard = createLeaveGuard(() => true)
assert.equal(guard.beforeLeave('/dashboard'), false)
assert.equal(guard.confirm(), '/dashboard')
// 这次跳转被别处直接拦下:reset 必须在下次离开前收回「已放行」。
guard.reset()
assert.equal(guard.beforeLeave('/review'), false)
})
test('没有未保存修改时直接放行,且不产生待确认的离开', () => {
const guard = createLeaveGuard(() => false)
assert.equal(guard.beforeLeave('/standings'), true)
assert.equal(guard.confirm(), null)
})
+65 -10
View File
@@ -30,27 +30,82 @@ export function formatSeconds(seconds: number, fractionDigits = 2): string {
return `${sign}${body}`
}
/** `1:06.33` / `66.33` → 66.33;无法解析返回 null。 */
export function parseSeconds(text: string): number | null {
const trimmed = text.trim()
if (trimmed === '') return null
/** 成绩格解析失败的原因:报错文案要据此区分「空格子 / 秒位越界 / 写法不认识」。 */
export type SecondsParseFailure = 'empty' | 'seconds-overflow' | 'unrecognized'
const parts = trimmed.split(':')
if (parts.length > 2) return null
export interface SecondsParseResult {
value: number | null
/** value 为 null 时一定给出原因;解析成功时为 null。 */
failure: SecondsParseFailure | null
}
/** 全角数字与标点归一:中文输入法下「1:06」看起来和「1:06」一模一样。 */
function normalizeTimeText(text: string): string {
return text
.replace(/[0-9]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
.replace(/:/g, ':')
.replace(/./g, '.')
}
/** 中文时间单位写法:`1小时2分3秒` / `1分06秒` / `1分06` / `2分`。 */
const CN_TIME_UNIT = /[小时分秒]/
const CN_TIME_PATTERN =
/^(?:(\d+(?:\.\d+)?)小时)?(?:(\d+(?:\.\d+)?)分)?(?:(\d+(?:\.\d+)?)秒)?(?:(\d+(?:\.\d+)?))?$/
function parseChineseTime(text: string): SecondsParseResult {
const matched = CN_TIME_PATTERN.exec(text)
if (matched === null) return { value: null, failure: 'unrecognized' }
const [, hoursText, minutesText, secondsText, bareSecondsText] = matched
// 一个单位都没出现时正则会匹配空串,必须挡掉,避免把纯数字也吞进来。
if (hoursText === undefined && minutesText === undefined && secondsText === undefined) {
return { value: null, failure: 'unrecognized' }
}
// 「1分06」的 06 没带「秒」字,按秒理解;已经带「秒」时以它为准。
const secondsPart = secondsText ?? bareSecondsText
const seconds = secondsPart === undefined ? 0 : Number(secondsPart)
if (seconds >= 60) return { value: null, failure: 'seconds-overflow' }
const hours = hoursText === undefined ? 0 : Number(hoursText)
const minutes = minutesText === undefined ? 0 : Number(minutesText)
return { value: hours * 3600 + minutes * 60 + seconds, failure: null }
}
/**
* 与 parseSeconds 同一套规则,但解析失败时带上原因。
*
* 容错只加在「同一件事的不同写法」上:全角冒号 / 全角数字、`1分06秒` 这类中文写法;
* 秒位 ≥ 60 属于真的不合法(`1:70` 不是 70 秒),仍然拒绝,但原因单独标出来。
*/
export function parseSecondsDetail(text: string): SecondsParseResult {
const trimmed = text.trim()
if (trimmed === '') return { value: null, failure: 'empty' }
const normalized = normalizeTimeText(trimmed)
if (CN_TIME_UNIT.test(normalized)) return parseChineseTime(normalized)
const parts = normalized.split(':')
if (parts.length > 2) return { value: null, failure: 'unrecognized' }
const numbers: number[] = []
for (const part of parts) {
// Number('') 是 0,会把「1:」悄悄当成 60 秒,这里按无法识别处理。
if (part.trim() === '') return { value: null, failure: 'unrecognized' }
const value = Number(part)
if (!Number.isFinite(value)) return null
if (!Number.isFinite(value)) return { value: null, failure: 'unrecognized' }
numbers.push(value)
}
if (numbers.length === 1) return numbers[0] ?? null
if (numbers.length === 1) return { value: numbers[0] ?? null, failure: null }
const minutes = numbers[0] ?? 0
const seconds = numbers[1] ?? 0
if (seconds < 0 || seconds >= 60) return null
return minutes * 60 + seconds
if (seconds < 0) return { value: null, failure: 'unrecognized' }
if (seconds >= 60) return { value: null, failure: 'seconds-overflow' }
return { value: minutes * 60 + seconds, failure: null }
}
/** `1:06.33` / `66.33` → 66.33;无法解析返回 null。容错写法见 parseSecondsDetail。 */
export function parseSeconds(text: string): number | null {
return parseSecondsDetail(text).value
}
/** 按单位把比较值渲染成展示文本,如 `66.33` + `s` → `1:06.33`。 */
+2
View File
@@ -35,4 +35,6 @@ export {
formatSeconds,
parseResultValue,
parseSeconds,
parseSecondsDetail,
} from './format'
export type { SecondsParseFailure, SecondsParseResult } from './format'
+57 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { formatClock, formatDateTime, formatSeconds } from '../format'
import { formatClock, formatDateTime, formatSeconds, parseResultValue, parseSeconds, parseSecondsDetail } from '../format'
// 服务端 datetime('now') 存的是 UTC 且不带时区,必须按 UTC 解析,否则东八区整体早 8 小时。
test('formatDateTime 把无时区时间串按 UTC 解析', () => {
@@ -26,3 +26,59 @@ test('formatDateTime 对空值返回占位', () => {
assert.equal(formatDateTime(null), '—')
assert.equal(formatClock(null), '--:--:--')
})
test('parseSeconds 保留原有写法', () => {
assert.equal(parseSeconds('66'), 66)
assert.equal(parseSeconds('66.33'), 66.33)
assert.equal(parseSeconds('1:06'), 66)
assert.equal(parseSeconds('1:06.33'), 66.33)
assert.equal(parseSeconds('1:6'), 66)
assert.equal(parseSeconds(' 1:06 '), 66)
assert.equal(parseSeconds('0:59'), 59)
})
test('parseSeconds 认得全角冒号与全角数字', () => {
assert.equal(parseSeconds('1:06'), 66)
assert.equal(parseSeconds('1:06.33'), 66.33)
assert.equal(parseSeconds('1:06'), 66)
})
test('parseSeconds 认得中文时间写法', () => {
assert.equal(parseSeconds('1分06秒'), 66)
assert.equal(parseSeconds('1分6秒'), 66)
assert.equal(parseSeconds('1分06'), 66)
assert.equal(parseSeconds('1分6.5秒'), 66.5)
assert.equal(parseSeconds('2分'), 120)
assert.equal(parseSeconds('6秒'), 6)
assert.equal(parseSeconds('1小时2分3秒'), 3723)
assert.equal(parseSeconds('1小时'), 3600)
})
test('parseSeconds 秒位 ≥ 60 仍然拒绝,但原因可区分', () => {
for (const text of ['1:60', '1:70', '1:70', '1分60秒', '1分70秒']) {
assert.equal(parseSeconds(text), null, text)
assert.equal(parseSecondsDetail(text).failure, 'seconds-overflow', text)
}
// 「70:06」是 70 分钟,不是秒位越界,照旧接受。
assert.equal(parseSeconds('70:06'), 4206)
})
test('parseSecondsDetail 区分空 / 秒位越界 / 写法不认识', () => {
assert.deepEqual(parseSecondsDetail(' '), { value: null, failure: 'empty' })
assert.deepEqual(parseSecondsDetail('1:70'), { value: null, failure: 'seconds-overflow' })
assert.deepEqual(parseSecondsDetail('约1分06秒'), { value: null, failure: 'unrecognized' })
assert.deepEqual(parseSecondsDetail('1:2:3'), { value: null, failure: 'unrecognized' })
assert.deepEqual(parseSecondsDetail('abc'), { value: null, failure: 'unrecognized' })
assert.deepEqual(parseSecondsDetail('1:'), { value: null, failure: 'unrecognized' })
assert.deepEqual(parseSecondsDetail('1:06'), { value: 66, failure: null })
})
test('parseResultValue 的距离 / 计数分支不受时间容错影响', () => {
assert.equal(parseResultValue('12.34', 'm'), 12.34)
assert.equal(parseResultValue('12米', 'm'), 12)
assert.equal(parseResultValue('', 'm'), null)
// 该分支只做「去掉非数字字符」的宽松解析:'abc' 去掉后是空串,Number('') 为 0,属既有行为。
assert.equal(parseResultValue('abc', 'm'), 0)
// 时间写法不归这个分支管,行为保持不变。
assert.equal(parseResultValue('1分06秒', 'm'), 106)
})