diff --git a/README.md b/README.md index c12d779..0af9375 100644 --- a/README.md +++ b/README.md @@ -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);只作用于大屏,加载失败自动回落默认字体,字体文件随赛事项目包一起导出/导入 diff --git a/config.yaml b/config.yaml index 62ffebf..8cae27a 100644 --- a/config.yaml +++ b/config.yaml @@ -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 用内置默认 diff --git a/docs/上线检查清单.md b/docs/上线检查清单.md index 2d973f2..6362b52 100644 --- a/docs/上线检查清单.md +++ b/docs/上线检查清单.md @@ -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) diff --git a/docs/使用手册.md b/docs/使用手册.md index 70b5791..3336e37 100644 --- a/docs/使用手册.md +++ b/docs/使用手册.md @@ -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. 系统设置 diff --git a/docs/常见问题.md b/docs/常见问题.md index a63ee6a..6b4480f 100644 --- a/docs/常见问题.md +++ b/docs/常见问题.md @@ -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 这类`; +- 成绩格已经加宽,四位数也能看全。 + +有未保存的成绩时,切到别的菜单页面会先弹确认框,不会静默丢弃。 diff --git a/docs/部署指南.md b/docs/部署指南.md index ac38475..abfe986 100644 --- a/docs/部署指南.md +++ b/docs/部署指南.md @@ -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 输出定位正确、分辨率正确、字体正常 diff --git a/server/internal/config/config.go b/server/internal/config/config.go index 072c68b..946114d 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -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/(拦截类); + // 留空时用 /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() diff --git a/server/internal/config/config_test.go b/server/internal/config/config_test.go index de2fe3d..15d1d24 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/config/config_test.go @@ -63,3 +63,33 @@ func TestApplyDataDir(t *testing.T) { }) } } + +// 日志配置:默认值开箱即用,配了 log.dir 就按它走,不配就落在 /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) + } +} diff --git a/server/internal/httpapi/fonts.go b/server/internal/httpapi/fonts.go index 79c8fec..da05cbf 100644 --- a/server/internal/httpapi/fonts.go +++ b/server/internal/httpapi/fonts.go @@ -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) diff --git a/server/internal/httpapi/handlers_config.go b/server/internal/httpapi/handlers_config.go index 13f61ed..fa5e675 100644 --- a/server/internal/httpapi/handlers_config.go +++ b/server/internal/httpapi/handlers_config.go @@ -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) diff --git a/server/internal/httpapi/handlers_system.go b/server/internal/httpapi/handlers_system.go index b56eb9c..225ed15 100644 --- a/server/internal/httpapi/handlers_system.go +++ b/server/internal/httpapi/handlers_system.go @@ -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}) diff --git a/server/internal/httpapi/intercept_log_test.go b/server/internal/httpapi/intercept_log_test.go new file mode 100644 index 0000000..34119c9 --- /dev/null +++ b/server/internal/httpapi/intercept_log_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/log_files_test.go b/server/internal/httpapi/log_files_test.go new file mode 100644 index 0000000..1e95060 --- /dev/null +++ b/server/internal/httpapi/log_files_test.go @@ -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) + } +} diff --git a/server/internal/httpapi/origin.go b/server/internal/httpapi/origin.go new file mode 100644 index 0000000..04beaa3 --- /dev/null +++ b/server/internal/httpapi/origin.go @@ -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) +} diff --git a/server/internal/httpapi/response.go b/server/internal/httpapi/response.go index 14b7549..52602d3 100644 --- a/server/internal/httpapi/response.go +++ b/server/internal/httpapi/response.go @@ -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{ diff --git a/server/internal/httpapi/rules_handlers.go b/server/internal/httpapi/rules_handlers.go index 83face9..30aecb4 100644 --- a/server/internal/httpapi/rules_handlers.go +++ b/server/internal/httpapi/rules_handlers.go @@ -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{ diff --git a/server/internal/httpapi/server.go b/server/internal/httpapi/server.go index 7905dde..5ed154f 100644 --- a/server/internal/httpapi/server.go +++ b/server/internal/httpapi/server.go @@ -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+"接口尚未接入")) } } diff --git a/server/internal/logging/logging.go b/server/internal/logging/logging.go new file mode 100644 index 0000000..aba6871 --- /dev/null +++ b/server/internal/logging/logging.go @@ -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 +} diff --git a/server/internal/logging/logging_test.go b/server/internal/logging/logging_test.go new file mode 100644 index 0000000..1c654c9 --- /dev/null +++ b/server/internal/logging/logging_test.go @@ -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) + } +} diff --git a/server/internal/logging/origin.go b/server/internal/logging/origin.go new file mode 100644 index 0000000..9be87c8 --- /dev/null +++ b/server/internal/logging/origin.go @@ -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 "" + } + // 本文件位于 /internal/logging/origin.go,向上三级即 。 + 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) +} diff --git a/server/internal/logging/rotate.go b/server/internal/logging/rotate.go new file mode 100644 index 0000000..73d6d32 --- /dev/null +++ b/server/internal/logging/rotate.go @@ -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 是「<类别>-.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 +} diff --git a/server/internal/middleware/audit.go b/server/internal/middleware/audit.go index 9ea4697..ac39129 100644 --- a/server/internal/middleware/audit.go +++ b/server/internal/middleware/audit.go @@ -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" } } diff --git a/server/internal/middleware/middleware.go b/server/internal/middleware/middleware.go index c348f81..506b00c 100644 --- a/server/internal/middleware/middleware.go +++ b/server/internal/middleware/middleware.go @@ -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) diff --git a/server/internal/middleware/middleware_test.go b/server/internal/middleware/middleware_test.go index 727ecaf..3f4be6c 100644 --- a/server/internal/middleware/middleware_test.go +++ b/server/internal/middleware/middleware_test.go @@ -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" diff --git a/server/main.go b/server/main.go index d9ac4f2..94017a8 100644 --- a/server/main.go +++ b/server/main.go @@ -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) diff --git a/web/src/admin/pages/EntryPage.vue b/web/src/admin/pages/EntryPage.vue index 50f616e..c455504 100644 --- a/web/src/admin/pages/EntryPage.vue +++ b/web/src/admin/pages/EntryPage.vue @@ -1,6 +1,6 @@