diff --git a/.gitignore b/.gitignore
index 356aeda..f6a8cac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,7 @@ data/
.run/
service_test.exe
service_test2*
+
+# 招聘爬虫本地离线验证产物(rc_*:验证脚本编译出的二进制 + 运行日志),不入库
+rc_*.log
+rc_verify*
diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md
index 70ca41e..0ec3f8f 100644
--- a/.workbuddy/memory/CHANGELOG.md
+++ b/.workbuddy/memory/CHANGELOG.md
@@ -1,6 +1,8 @@
# service.xpcool.com 变更记录
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
+2026-09-14 | CHG | **归档提交 09-13~09-14 压在工作区的两批改动(招聘爬虫健壮性 + 通知记录增强)**,11 改 9 增。① **招聘爬虫**:新增 `source_config.go`(`crawl_source.config` 这个 VARCHAR(1000) JSON 的源级抓取参数解析——增量窗口/单次上限/分页/限速/自定义标准词,**全字段可选、解析失败即回退默认**,保证历史数据零迁移可用、一个脏配置不会拖垮整个调度)、`keywords.go`(链接筛选改「评分制」:URL 形态加分 + 标准词加分 + 排除词大幅减分达阈值入选,取代原先「URL 形态与文本语义必须同时命中」的与逻辑——该逻辑会系统性漏抓「补充工作人员的通知」「公开选调公务员简章」等标题变体且静默无报错,带 `keywords_test.go` 单测与 `testdata/`)、`manifest/sql/recruitment/004_crawl_source_status.sql`(`crawl_source` 增「最近一次运行状态」冗余列,规避 `Sources()` 全表扫只增不减的 `crawl_log`、随运行时间线性劣化);`crawler.go` 在既有两级抓取流程上做锚点抽取/过滤与编码回退的健壮性增强,`bark.go` 推送配合调整。② **通知模块**:`notice.go`/controller/api/dto 扩展通知日志的查询与操作(批次号、状态、重试次数、来源、耗时、详情、删除、清空)、`dto/notice_meta.go`(312 行,字典选项元数据)、`manifest/sql/017_notice_log_enhance.sql`(notice_log 扩列 + 新增接口权限,幂等可重放)。③ `.gitignore` 补 `rc_*` 挡掉本地验证产物(含 30MB 的 `rc_verify.exe`)。设计依据见 `docs/recruitment-crawler-design.md`
+
2026-09-13 | DOC | 项目整理与全局中文化:①重写 README.MD(原英文且路由陈旧 /api/v1 → 实际三分组 /api/service/{open,user,admin},补分层约定/RBAC 权限映射/gen dao 流程/本地启动);②重写 PROJECT_STRUCTURE.md(原英文目录树缺 house/recruitment/notice/job/serversecurity,标注手写 entity/do/dao 风险);③hack/hack.mk + hack-cli.mk 注释中文化;④common/doc.go + common/tools/doc.go 工具清单中文化,ip/main.go/cmd.go 英文注释中文化;⑤**校验提示与服务层错误信息中文化**(19 文件逾 90 处):api 层 v:"#提示" 中文(user/auth、tools/md5、tools/random、menu_manage、admin)、service 层 gerror.Wrap/response.Error 全部中文(admin/admin、admin/login、system/{role,menu,menu_manage,login_log}、user/auth、house/{community,listing,dashboard,transaction,presale}、notice、job、serversecurity、recruitment/{recruitment,crawler})、jwt.go、controller/admin/admin.go、各 service 的 panic("xxx implementation not registered")→"xxx 实现未注册";⑥api/user/login/login.go 补中文包文档。生成代码(entity/do/dao)英文注释按约定保持原样(DO NOT EDIT,重生成会覆盖)。仅注释/文档/字符串改动,无业务逻辑变更;read_lints 全绿,go build 因 proxy.golang.org 网络超时未跑通。详见 docs/change-log/2026-09-13.md
2026-08-27 | CHG | 登录接口从全量加密豁免,永远只加密密码字段:APICrypto plainRoutes 增 /system/auth/login(登录请求不整体加密,handler 直接收 {username, encryptedKey, encryptedData},登录响应因无会话密钥保持明文);ResolveLogin 分支顺序调整——密码字段密文优先解密,其次 fullBody 明文、开发 allowPlain。全量模式验证 5 项(登录明文响应/info 密文/解密成功/未加密拒绝/登出)+ dev 回归 15 项全过
diff --git a/api/notice/notice.go b/api/notice/notice.go
index 7174afe..50ab804 100644
--- a/api/notice/notice.go
+++ b/api/notice/notice.go
@@ -80,35 +80,117 @@ type NoticeRuleDeleteReq struct {
type NoticeRuleDeleteRes struct{}
-// ---------- 通知日志 ----------
+// ---------- 通知历史记录 ----------
+// NoticeLogItem 通知历史记录行:落库字段 + 渠道/事件/类型/分组/接收人等派生展示字段。
type NoticeLogItem struct {
Id uint64 `json:"id"`
- RuleId uint64 `json:"ruleId"`
- EventType string `json:"eventType"`
- ChannelCode string `json:"channelCode"`
- UserId uint64 `json:"userId"`
- Target string `json:"target"`
- Title string `json:"title"`
- Body string `json:"body"`
- Result int `json:"result"`
- Error string `json:"error"`
- CreatedAt string `json:"createdAt"`
+ BatchId string `json:"batchId"` // 批次号(同一次业务触发)
+ RuleId uint64 `json:"ruleId"` // 规则ID
+ RuleName string `json:"ruleName"` // 规则名称
+ EventType string `json:"eventType"` // 事件编码
+ EventName string `json:"eventName"` // 事件名称
+ NoticeType string `json:"noticeType"` // 类型编码
+ TypeName string `json:"typeName"` // 类型名称
+ Group string `json:"group"` // 分组编码
+ GroupName string `json:"groupName"` // 分组名称
+ ChannelCode string `json:"channelCode"` // 渠道编码
+ ChannelName string `json:"channelName"` // 渠道名称
+ UserId uint64 `json:"userId"` // 接收用户ID
+ UserName string `json:"userName"` // 接收人
+ Target string `json:"target"` // 发送目标
+ Title string `json:"title"` // 标题
+ Body string `json:"body"` // 内容
+ Status int `json:"status"` // 0待发送 1成功 2失败
+ StatusName string `json:"statusName"` // 状态名称
+ Result int `json:"result"` // 兼容旧字段 1成功 0失败
+ Error string `json:"error"` // 错误信息
+ RetryCount int `json:"retryCount"` // 已重试次数
+ DurationMs int `json:"durationMs"` // 耗时(毫秒)
+ Source string `json:"source"` // 触发来源 auto/manual/test
+ Remark string `json:"remark"` // 备注
+ CreatedAt string `json:"createdAt"` // 通知时间
}
type NoticeLogListReq struct {
- g.Meta `path:"/notice/log/list" method:"post" tags:"Admin/Notice" summary:"通知发送记录分页"`
+ g.Meta `path:"/notice/log/list" method:"post" tags:"Admin/Notice" summary:"通知历史记录分页"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
- EventType string `json:"eventType"`
- ChannelCode string `json:"channelCode"`
- Result int `json:"result"` // 0全部 1成功 2失败
- DateFrom string `json:"dateFrom"`
- DateTo string `json:"dateTo"`
+ Keyword string `json:"keyword"` // 标题/内容/目标/错误 模糊
+ NoticeType string `json:"noticeType"` // 类型编码
+ Group string `json:"group"` // 分组编码
+ EventType string `json:"eventType"` // 事件编码
+ ChannelCode string `json:"channelCode"` // 渠道编码
+ UserId uint64 `json:"userId"` // 接收用户ID
+ BatchId string `json:"batchId"` // 批次号
+ Status int `json:"status"` // 0全部 1成功 2失败
+ Result int `json:"result"` // 兼容旧参数:1成功 2失败
+ DateFrom string `json:"dateFrom"` // 起始时间
+ DateTo string `json:"dateTo"` // 结束时间
+ OrderBy string `json:"orderBy"` // createdAt(默认) | durationMs
+ OrderDir string `json:"orderDir"` // desc(默认) | asc
}
type NoticeLogListRes struct {
- List []*NoticeLogItem `json:"list"`
- Total int `json:"total"`
+ List []*NoticeLogItem `json:"list"`
+ Total int `json:"total"`
+ Stats *NoticeLogStatsItem `json:"stats"` // 当前筛选条件下的统计概览
+}
+
+// NoticeLogStatsItem 统计概览。
+type NoticeLogStatsItem struct {
+ Total int `json:"total"`
+ Success int `json:"success"`
+ Failed int `json:"failed"`
+ SuccessRate int `json:"successRate"`
+}
+
+// NoticeLogDetailReq 通知历史记录详情。
+type NoticeLogDetailReq struct {
+ g.Meta `path:"/notice/log/detail" method:"post" tags:"Admin/Notice" summary:"通知历史记录详情"`
+ Id uint64 `json:"id" v:"required"`
+}
+
+type NoticeLogDetailRes struct {
+ Item *NoticeLogItem `json:"item"`
+}
+
+// NoticeLogDeleteReq 删除通知历史记录(支持批量)。
+type NoticeLogDeleteReq struct {
+ g.Meta `path:"/notice/log/delete" method:"post" tags:"Admin/Notice" summary:"删除通知历史记录"`
+ Ids []uint64 `json:"ids" v:"required|min-length:1"`
+}
+
+type NoticeLogDeleteRes struct {
+ Deleted int `json:"deleted"`
+}
+
+// NoticeLogClearReq 清空通知历史记录(按时间范围,可选保留最近 N 天)。
+type NoticeLogClearReq struct {
+ g.Meta `path:"/notice/log/clear" method:"post" tags:"Admin/Notice" summary:"清空通知历史记录"`
+ KeepDays int `json:"keepDays"` // 保留最近 N 天(>0 时忽略 DateFrom/DateTo)
+ DateFrom string `json:"dateFrom"` // 清空起始时间
+ DateTo string `json:"dateTo"` // 清空结束时间
+}
+
+type NoticeLogClearRes struct {
+ Deleted int `json:"deleted"`
+}
+
+// ---------- 字典选项 ----------
+type NoticeMetaItem struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+}
+
+type NoticeMetaOptionsReq struct {
+ g.Meta `path:"/notice/log/options" method:"post" tags:"Admin/Notice" summary:"通知字典选项(渠道/事件/类型/分组)"`
+}
+
+type NoticeMetaOptionsRes struct {
+ Channels []*NoticeMetaItem `json:"channels"`
+ Events []*NoticeMetaItem `json:"events"`
+ Types []*NoticeMetaItem `json:"types"`
+ Groups []*NoticeMetaItem `json:"groups"`
}
// ---------- 测试发送 ----------
diff --git a/docs/recruitment-crawler-design.md b/docs/recruitment-crawler-design.md
new file mode 100644
index 0000000..a75a31c
--- /dev/null
+++ b/docs/recruitment-crawler-design.md
@@ -0,0 +1,272 @@
+# 招聘考试抓取推送模块 · 完善设计方案
+
+> 模块路径:`api/recruitment/`、`internal/controller/recruitment/`、`internal/service/recruitment/`
+> 独立数据库:`recruitment`(与主库 `service` 隔离)
+> 文档定位:本次「抓取健壮性」专项设计,供评审后实施。
+
+---
+
+## 一、现状总览
+
+### 1.1 架构分层
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 调度层 auto_job 表(主库 service.auto_job,由 job 模块驱动) │
+│ ├─ recruit-crawl-hourly 每小时 触发 crawl --all │
+│ └─ recruit-push-daily 每日08:00 触发 push --daily │
+└───────────────────────────┬─────────────────────────────────┘
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ 服务层 internal/service/recruitment/ │
+│ crawler.go 抓取主流程(静态两级 / SPA 占位 / curl 回退)│
+│ scheduler.go RegisterTasks 注册 cron 到 auto_job │
+│ bark.go Bark 推送(单条 + 早报汇总) │
+│ recruitment.go 查询/统计/订阅 CRUD │
+└───────────────────────────┬─────────────────────────────────┘
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ 数据层 recruitment 库 │
+│ crawl_source 数据源配置(source_type 1静态 2SPA 3登录 4附件)│
+│ recruitment_info 公告主表(UNIQUE fingerprint 去重) │
+│ crawl_log / push_log 运行日志 │
+│ push_subscription / organization │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 1.2 已具备的能力
+
+| 能力 | 实现位置 | 说明 |
+|---|---|---|
+| 静态列表抓取 | `genericStaticCrawl` | 列表页 → 详情页两级抓取 |
+| 反 WAF | `fetchHTML` | **curl 子进程优先**,失败回退 Go `gclient` |
+| 编码兼容 | `fetchHTML` | gbk/gb2312 → utf-8 转码 |
+| 去重 | `fingerprint` | `源ID + 标题 + 日期 + URL` 哈希,唯一索引 |
+| 跨源聚合 | `group_key` | 标题+日期归一化,识别同公告多源转载 |
+| 增量控制 | `crawlInfo` | `time.Since(publishDate) > 30天` 跳过 |
+| 调度 | `auto_job` 表 | 支持启停 / 改 cron / 运行日志 |
+| 推送 | `bark.go` | 订阅按地区+分类过滤,支持早报汇总 |
+| 观测 | `crawl_log` / `fail_count` | 记录抓取量、错误、连续失败次数 |
+
+### 1.3 数据源现状(`002_seed_sources.sql`)
+
+| 源 | 类型 | 地区 | 状态 |
+|---|---|---|---|
+| 贵阳市人社局-人事招考 | 1 静态 | 贵阳 | **启用** |
+| 贵阳市政府-人事招考 | 1 静态 | 贵阳 | **启用** |
+| 贵州人事考试信息网 | 2 SPA | 省直 | 禁用(待 POC) |
+| 贵州国资央企招聘平台 | 2 SPA | 省直 | 禁用(待 POC) |
+| 贵州茅台集团 | 4 附件型 | 省直 | 禁用(待 POC) |
+
+> **关键结论**:当前「招聘聚合」实际只覆盖 **2 个贵阳本地静态源**,省直/央企/国企类公告完全未覆盖。
+
+---
+
+## 二、问题清单
+
+按严重度分级,本次专项聚焦 **P0 / P1**。
+
+### P0-1 列表页链接过滤过严,存在系统性漏抓
+
+**位置**:`crawler.go` → `filterArticleAnchors`
+
+```go
+// 现状伪代码
+if urlHint && textHint { // 两个条件必须同时满足才认为是公告
+ keep(a)
+}
+```
+
+**问题**:`textHint` 要求锚文本包含「招聘/考录/招考/公告」等词。政府站的公告标题变体极多,例如:
+
+- 「XX局关于2026年**补充**工作人员的**通知**」→ 无「招聘」,漏
+- 「XX市2026年**公开选调**公务员**简章**」→ 无标准词,漏
+- 「XX单位**引进**高层次人才**启事**」→ 漏
+- 列表页若用「more」分页锚点,锚文本为空 → 漏
+
+**影响**:漏抓是静默的——`crawl_log` 只显示 fetched 变少,不会报错。这是当前最影响数据完整性的缺陷。
+
+**方案**:改为**「URL 形态命中」为主、「文本命中」放宽**的组合评分:
+
+```
+score = 0
+if url 匹配详情页正则(/art/、/\d{6,}\.html、/info/、content?id=) then score += 2
+if 文本命中标准词(招聘|考录|招考|选调|遴选|引进|人才|公告|简章|启事|通知) then score += 2
+if 文本命中排除词(政策解读|常见问题|办事指南|下载中心|联系我们) then score -= 5
+if 锚文本为空 或 长度<6 then score -= 2
+if score >= 2 then keep(a)
+```
+
+同时把「词表」外置到 `crawl_source.config`,不同站点可定制,无需改代码。
+
+### P0-2 发布日期误取(整页第一个日期)
+
+**位置**:`crawler.go` → `extractDetail` 调 `firstDate(html)`
+
+**问题**:政府详情页头部常有「今天是2026年9月13日 星期五」,`firstDate` 取全页第一个日期即取到它。导致 `publish_date` 全部错成抓取当天,进而:
+
+- 增量窗口判断失效(永远「30天内」,全量回抓)
+- 看板趋势图失真(全部堆在当天)
+- 推送早报的「今日新增」虚高
+
+**方案**:按优先级分段抽取,取**第一个可信命中**:
+
+1. 优先 ``
+2. 其次带语义容器的正则:`
...2026-09-13...`
+3. 再次匹配「发布时间:/发布日期:/日期:」前缀后的日期
+4. 全部未命中 → **置空**,并在 `crawl_log.error` 记「日期未识别」,而非盲目取第一个
+
+### P1-3 增量窗口硬编码 30 天
+
+**位置**:`crawler.go` → `crawlInfo` 内 `30*24*time.Hour`
+
+**方案**:读取 `crawl_source.config.incrDays`,默认 30,允许按源配置。`Force` 手动触发时忽略该限制(现有 `force` 语义保留)。
+
+### P1-4 `crawl_source.config` 字段完全未被使用
+
+**现状**:表里有 `config VARCHAR(1000) COMMENT '适配器扩展配置(JSON)'`,代码中**零引用**。
+
+**方案**:定义并启用 `SourceConfig` 结构,让该字段真正生效:
+
+```jsonc
+{
+ "incrDays": 30, // 增量窗口天数
+ "listSelector": "ul.list li a", // 列表链接选择器(覆盖默认猜测)
+ "detailSelector": ".content", // 详情正文容器
+ "includeWords": ["招聘","选调"], // 覆盖默认标准词
+ "excludeWords": ["政策解读"], // 覆盖默认排除词
+ "datePatterns": ["发布时间:(\\d{4}-\\d{2}-\\d{2})"],
+ "maxPages": 3, // 列表分页最大翻页数
+ "delayMs": 800 // 详情页抓取间隔,避免过快
+}
+```
+
+### P1-5 `Sources()` 无 LIMIT 全表扫描
+
+**位置**:`recruitment.go` → `Sources()`
+
+```go
+_ = dao.CrawlLog.Ctx(ctx).OrderDesc("id").Scan(&logs) // 无分页
+```
+
+**问题**:`crawl_log` 只增不减,每次打开「数据源状态」页都全量拉取到内存,随运行时间线性劣化。
+
+**方案**:改为**取每个源最近 1 条**。两种实现任选:
+
+- 子查询:`WHERE id IN (SELECT MAX(id) FROM crawl_log GROUP BY source_id)`
+- 或新增 `crawl_source.last_log_*` 冗余列(写入时同步),查询零 JOIN
+
+推荐后者,顺带解决 P1-6。
+
+### P1-6 失败无告警、无自动禁用
+
+**现状**:`fail_count` 累加了,但**没有任何消费者**。源静默失效(改版/封禁)不会被发现。
+
+**方案**:
+- 连续失败达阈值(默认 5)→ 自动置 `enabled=0`,并推送一条「数据源已自动禁用」告警;
+- 抓取成功 → `fail_count` 归零(当前是否归零需确认,应立即归零);
+- 可选:失败达 3 次时先推送一次预警(未禁用)。
+
+### P2-7 调试日志残留
+
+**位置**:`crawler.go` 中 6 处 `g.Log().Warningf(ctx, "[recruit-debug] ...")`
+
+**方案**:删除,或降为 `Debugf`。生产日志不应被调试信息污染。
+
+### P2-8 Bark 模块英文日志(翻译遗漏)
+
+**位置**:`bark.go` 第 92 / 98 / 128 / 174 行附近(`load subscriptions failed` 等)
+
+**方案**:按项目「中文优先」约定统一中文化。
+
+### P2-9 `push_time` 字段是死配置
+
+**位置**:`push_subscription.push_time` vs `scheduler.go` 的 `recruit-push-daily` 固定 `0 0 8 * * *`
+
+**问题**:订阅里设 09:30 不生效,永远 08:00 推送。
+
+**方案**(本次不做,列入后续):`recruit-push-daily` 改为每小时跑一次,只处理 `push_time` 落在当前小时的订阅;或用动态 cron 按订阅分时注册。
+
+---
+
+## 三、SPA / 附件型源接入(后续阶段)
+
+本次不做,但设计上预留。三个禁用源的技术路径预判:
+
+| 源 | 预判路径 | 难度 |
+|---|---|---|
+| 贵州人事考试信息网 | hash 路由背后通常是 `POST /api/xxx/list` 返回 JSON,需抓包定位接口 | 中 |
+| 贵州国资央企招聘平台(iguopin) | 国聘系平台接口形态统一,通常有公开列表 API | 中 |
+| 茅台集团官网 | 附件型:列表页→详情页→PDF 附件→解析 PDF 文本 | 高 |
+
+**建议**:新增 `source_type=5 接口型`,`config` 存接口地址与字段映射,用 JSONPath 提取。这样 SPA 源无需模拟浏览器,直接调底层接口,稳定且快。
+
+---
+
+## 四、表结构变更(本次仅 P1 相关)
+
+沿用「不破坏现有数据」原则,全部为**新增列**,无需数据迁移。
+
+### 4.1 `crawl_source` 新增列
+
+```sql
+ALTER TABLE `crawl_source`
+ ADD COLUMN `last_log_at` DATETIME NULL DEFAULT NULL COMMENT '最近一次抓取时间(冗余,避免全表扫描 crawl_log)' AFTER `fail_count`,
+ ADD COLUMN `last_log_fetched` INT NOT NULL DEFAULT 0 COMMENT '最近一次抓取条数(冗余)' AFTER `last_log_at`,
+ ADD COLUMN `last_log_new` INT NOT NULL DEFAULT 0 COMMENT '最近一次新增条数(冗余)' AFTER `last_log_fetched`,
+ ADD COLUMN `last_log_error` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '最近一次错误信息(冗余)' AFTER `last_log_new`;
+```
+
+### 4.2 迁移脚本
+
+新增 `manifest/sql/recruitment/004_crawl_source_status.sql`,幂等(`ADD COLUMN IF NOT EXISTS` 或部署前判存在)。
+
+---
+
+## 五、实施计划
+
+### 阶段一:抓取健壮性(本次,P0 + P1)
+
+| # | 任务 | 涉及文件 |
+|---|---|---|
+| 1 | 链接过滤改为评分制 + 词表可配 | `crawler.go` |
+| 2 | 发布日期分段抽取 + 未识别记日志 | `crawler.go` |
+| 3 | 增量窗口读 `config.incrDays` | `crawler.go` |
+| 4 | 启用 `SourceConfig` 结构(含选择器覆盖、限速) | `crawler.go`(新增 `source_config.go`) |
+| 5 | `Sources()` 查最近日志改为冗余列 | `recruitment.go` + 表变更 |
+| 6 | 失败达阈值自动禁用 + 告警推送 | `crawler.go` + `bark.go` |
+| 7 | 删除调试日志、`bark.go` 中文化 | `crawler.go`、`bark.go` |
+| 8 | 迁移脚本 `004_crawl_source_status.sql` | `manifest/sql/recruitment/` |
+
+**验收标准**:
+- 2 个启用源抓取条数不低于手工核对数量(目标:不漏抓);
+- `publish_date` 与页面实际发布日期一致(抽样 10 条);
+- `crawl_log` 可按源查看最近一次结果;
+- 手动触发 `force=true` 可绕过增量窗口全量回溯。
+
+### 阶段二:推送体系(后续)
+
+- `push_time` 真正生效(订阅分时推送);
+- 推送结果统计(订阅级成功率);
+- 失败重试(Bark 失败重试 2 次,指数退避)。
+
+### 阶段三:源扩展(后续)
+
+- `source_type=5 接口型` + JSONPath 映射;
+- 接入贵州人事考试信息网、国资央企平台;
+- 茅台附件型:PDF 下载 + 文本抽取。
+
+### 阶段四:运维增强(后续)
+
+- `crawl_log` / `push_log` 定期归档(保留 90 天);
+- 抓取质量日报(新增数、失败源、异常波动告警)。
+
+---
+
+## 六、风险与注意事项
+
+1. **抓取频率与合规**:政府站对高频访问敏感,`delayMs` 默认 800ms,单源单次抓取控制在分钟级;建议遵守 robots.txt 与站点条款。
+2. **词表误伤**:评分制若阈值过低会引入噪声(如「招聘会预告」),需先用真实列表页离线验证,再上线。
+3. **日期置空的影响**:P0-2 改为「未识别则置空」后,依赖 `publish_date` 的统计会短期波动,属预期(此前是错误数据)。
+4. **自动禁用需谨慎**:阈值过低会因偶发网络抖动误禁。建议结合「连续」失败(中间成功即归零),并推送告警以便人工复核。
+5. **表变更走迁移脚本**:禁止手工改库;生成代码(若后续跑 `gf gen dao`)需注意本模块 entity 为**手写**,勿被覆盖。
diff --git a/internal/controller/notice/notice.go b/internal/controller/notice/notice.go
index 6b51ad0..760e980 100644
--- a/internal/controller/notice/notice.go
+++ b/internal/controller/notice/notice.go
@@ -77,25 +77,68 @@ func (c *Controller) RuleDelete(ctx context.Context, req *noticev1.NoticeRuleDel
return ¬icev1.NoticeRuleDeleteRes{}, nil
}
-// LogList 通知发送记录分页。
+// LogList 通知历史记录分页(含统计概览)。
func (c *Controller) LogList(ctx context.Context, req *noticev1.NoticeLogListReq) (res *noticev1.NoticeLogListRes, err error) {
- list, total, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
- Page: req.Page, Size: req.Size, EventType: req.EventType,
- ChannelCode: req.ChannelCode, Result: req.Result, DateFrom: req.DateFrom, DateTo: req.DateTo,
+ list, total, stats, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
+ Page: req.Page, Size: req.Size, Keyword: req.Keyword,
+ NoticeType: req.NoticeType, Group: req.Group, EventType: req.EventType,
+ ChannelCode: req.ChannelCode, UserId: req.UserId, BatchId: req.BatchId,
+ Status: req.Status, Result: req.Result,
+ DateFrom: req.DateFrom, DateTo: req.DateTo,
+ OrderBy: req.OrderBy, OrderDir: req.OrderDir,
})
if err != nil {
return nil, err
}
out := make([]*noticev1.NoticeLogItem, 0, len(list))
for i := range list {
- v := &list[i]
- out = append(out, ¬icev1.NoticeLogItem{
- Id: v.Id, RuleId: v.RuleId, EventType: v.EventType, ChannelCode: v.ChannelCode,
- UserId: v.UserId, Target: v.Target, Title: v.Title, Body: v.Body,
- Result: v.Result, Error: v.Error, CreatedAt: v.CreatedAt,
- })
+ out = append(out, toLogItem(&list[i]))
}
- return ¬icev1.NoticeLogListRes{List: out, Total: total}, nil
+ res = ¬icev1.NoticeLogListRes{List: out, Total: total}
+ if stats != nil {
+ res.Stats = ¬icev1.NoticeLogStatsItem{
+ Total: stats.Total, Success: stats.Success,
+ Failed: stats.Failed, SuccessRate: stats.SuccessRate,
+ }
+ }
+ return res, nil
+}
+
+// LogDetail 通知历史记录详情。
+func (c *Controller) LogDetail(ctx context.Context, req *noticev1.NoticeLogDetailReq) (res *noticev1.NoticeLogDetailRes, err error) {
+ vo, err := notice.Notice().LogDetail(ctx, req.Id)
+ if err != nil {
+ return nil, err
+ }
+ return ¬icev1.NoticeLogDetailRes{Item: toLogItem(vo)}, nil
+}
+
+// LogDelete 删除通知历史记录(支持批量)。
+func (c *Controller) LogDelete(ctx context.Context, req *noticev1.NoticeLogDeleteReq) (res *noticev1.NoticeLogDeleteRes, err error) {
+ n, err := notice.Notice().LogDelete(ctx, req.Ids)
+ if err != nil {
+ return nil, err
+ }
+ return ¬icev1.NoticeLogDeleteRes{Deleted: n}, nil
+}
+
+// LogClear 清空通知历史记录。
+func (c *Controller) LogClear(ctx context.Context, req *noticev1.NoticeLogClearReq) (res *noticev1.NoticeLogClearRes, err error) {
+ n, err := notice.Notice().LogClear(ctx, req.KeepDays, req.DateFrom, req.DateTo)
+ if err != nil {
+ return nil, err
+ }
+ return ¬icev1.NoticeLogClearRes{Deleted: n}, nil
+}
+
+// MetaOptions 通知字典选项(渠道/事件/类型/分组)。
+func (c *Controller) MetaOptions(ctx context.Context, req *noticev1.NoticeMetaOptionsReq) (res *noticev1.NoticeMetaOptionsRes, err error) {
+ return ¬icev1.NoticeMetaOptionsRes{
+ Channels: toMetaItems(dto.NoticeChannelOptions()),
+ Events: toMetaItems(dto.NoticeEventOptions()),
+ Types: toMetaItems(dto.NoticeTypeOptions()),
+ Groups: toMetaItems(dto.NoticeGroupOptions()),
+ }, nil
}
// Test 测试通知发送。
@@ -106,3 +149,29 @@ func (c *Controller) Test(ctx context.Context, req *noticev1.NoticeTestReq) (res
}
return ¬icev1.NoticeTestRes{Result: ok, Message: msg}, nil
}
+
+// toLogItem dto → API 契约转换。
+func toLogItem(v *dto.NoticeLogVO) *noticev1.NoticeLogItem {
+ return ¬icev1.NoticeLogItem{
+ Id: v.Id, BatchId: v.BatchId, RuleId: v.RuleId, RuleName: v.RuleName,
+ EventType: v.EventType, EventName: v.EventName,
+ NoticeType: v.NoticeType, TypeName: v.TypeName,
+ Group: v.Group, GroupName: v.GroupName,
+ ChannelCode: v.ChannelCode, ChannelName: v.ChannelName,
+ UserId: v.UserId, UserName: v.UserName, Target: v.Target,
+ Title: v.Title, Body: v.Body,
+ Status: v.Status, StatusName: v.StatusName,
+ Result: v.Result, Error: v.Error,
+ RetryCount: v.RetryCount, DurationMs: v.DurationMs,
+ Source: v.Source, Remark: v.Remark, CreatedAt: v.CreatedAt,
+ }
+}
+
+// toMetaItems dto 字典项 → API 契约转换。
+func toMetaItems(list []dto.NoticeMetaItem) []*noticev1.NoticeMetaItem {
+ out := make([]*noticev1.NoticeMetaItem, 0, len(list))
+ for i := range list {
+ out = append(out, ¬icev1.NoticeMetaItem{Code: list[i].Code, Name: list[i].Name})
+ }
+ return out
+}
diff --git a/internal/model/do/notice.go b/internal/model/do/notice.go
index 567bf7f..15f231c 100644
--- a/internal/model/do/notice.go
+++ b/internal/model/do/notice.go
@@ -35,15 +35,21 @@ type NoticeRule struct {
type NoticeLog struct {
g.Meta `orm:"table:notice_log, do:true"`
Id any //
+ BatchId any // 批次号
RuleId any // 规则ID
- EventType any // 事件类型
+ EventType any // 事件编码
ChannelCode any // 渠道编码
UserId any // 用户ID
Target any // 发送目标
Title any // 标题
Body any // 内容
- Result any // 结果
+ Status any // 0待发送 1成功 2失败
+ Result any // 1成功 0失败(兼容旧字段)
Error any // 错误
+ RetryCount any // 已重试次数
+ DurationMs any // 耗时(毫秒)
+ Source any // 触发来源
+ Remark any // 备注
CreatedAt any //
}
diff --git a/internal/model/do/recruitment.go b/internal/model/do/recruitment.go
index f22aba0..4475d31 100644
--- a/internal/model/do/recruitment.go
+++ b/internal/model/do/recruitment.go
@@ -43,21 +43,25 @@ type Organization struct {
// CrawlSource 数据源 DO。
type CrawlSource struct {
- g.Meta `orm:"table:crawl_source, do:true"`
- Id any //
- Name any // 数据源名称
- BaseUrl any // 站点基础URL
- SourceType any // 1静态 2SPA 3需登录 4附件
- Category any // 默认分类
- Region any // 默认地区
- ListPath any // 列表路径/接口
- Config any // 扩展配置(JSON)
- Enabled any // 是否启用
- CronExpr any // 调度表达式
- LastSuccessAt any // 最近成功抓取时间
- FailCount any // 连续失败次数
- CreatedAt any //
- UpdatedAt any //
+ g.Meta `orm:"table:crawl_source, do:true"`
+ Id any //
+ Name any // 数据源名称
+ BaseUrl any // 站点基础URL
+ SourceType any // 1静态 2SPA 3需登录 4附件
+ Category any // 默认分类
+ Region any // 默认地区
+ ListPath any // 列表路径/接口
+ Config any // 扩展配置(JSON)
+ Enabled any // 是否启用
+ CronExpr any // 调度表达式
+ LastSuccessAt any // 最近成功抓取时间
+ FailCount any // 连续失败次数
+ LastLogAt any // 最近一次抓取时间(冗余)
+ LastLogFetched any // 最近一次抓取条数(冗余)
+ LastLogNew any // 最近一次新增条数(冗余)
+ LastLogError any // 最近一次错误信息(冗余)
+ CreatedAt any //
+ UpdatedAt any //
}
// CrawlLog 抓取日志 DO。
diff --git a/internal/model/dto/notice.go b/internal/model/dto/notice.go
index 300cb9a..c9a7a9d 100644
--- a/internal/model/dto/notice.go
+++ b/internal/model/dto/notice.go
@@ -47,30 +47,76 @@ type NoticeRuleInput struct {
Enabled int
}
-// NoticeLogVO 通知记录出参。
+// NoticeLogVO 通知历史记录出参。
+// 除落库字段外,额外补齐「渠道名称/事件名称/类型/分组/接收人」等派生字段,供表格直接展示。
type NoticeLogVO struct {
- Id uint64
- RuleId uint64
- EventType string
- ChannelCode string
- UserId uint64
- Target string
- Title string
- Body string
- Result int
- Error string
- CreatedAt string
+ Id uint64 `json:"id"` // 主键
+ BatchId string `json:"batchId"` // 批次号(同一次业务触发的多条投递共用)
+ RuleId uint64 `json:"ruleId"` // 规则ID
+ RuleName string `json:"ruleName"` // 规则名称(派生,规则已删除则为空)
+ EventType string `json:"eventType"` // 事件编码
+ EventName string `json:"eventName"` // 事件名称(派生)
+ NoticeType string `json:"noticeType"` // 类型编码(派生)
+ TypeName string `json:"typeName"` // 类型名称(派生)
+ Group string `json:"group"` // 分组编码(派生)
+ GroupName string `json:"groupName"` // 分组名称(派生)
+ ChannelCode string `json:"channelCode"` // 渠道编码
+ ChannelName string `json:"channelName"` // 渠道名称(派生)
+ UserId uint64 `json:"userId"` // 接收用户ID
+ UserName string `json:"userName"` // 接收人(昵称/账号,派生)
+ Target string `json:"target"` // 发送目标(设备key/token)
+ Title string `json:"title"` // 标题
+ Body string `json:"body"` // 内容
+ Status int `json:"status"` // 0待发送 1成功 2失败
+ StatusName string `json:"statusName"` // 状态名称(派生)
+ Result int `json:"result"` // 兼容旧字段:1成功 0失败
+ Error string `json:"error"` // 错误信息
+ RetryCount int `json:"retryCount"` // 已重试次数
+ DurationMs int `json:"durationMs"` // 渠道接口耗时(毫秒)
+ Source string `json:"source"` // 触发来源:auto/manual/test
+ Remark string `json:"remark"` // 备注(如测试说明)
+ CreatedAt string `json:"createdAt"` // 通知时间
}
-// NoticeLogFilter 通知记录筛选。
+// NoticeLogFilter 通知历史记录筛选(分页 + 多维条件)。
type NoticeLogFilter struct {
- Page int
- Size int
- EventType string
- ChannelCode string
- Result int // 0全部 1成功 2失败
- DateFrom string
- DateTo string
+ Page int // 页码
+ Size int // 每页条数
+ Keyword string // 关键字:标题/内容/目标/错误 模糊
+ NoticeType string // 类型编码(job/security/...)
+ Group string // 分组编码(auto_job/server/...)
+ EventType string // 事件编码
+ ChannelCode string // 渠道编码
+ UserId uint64 // 接收用户ID
+ BatchId string // 批次号
+ Status int // 0全部 1成功 2失败(兼容旧的 result 字段)
+ Result int // 兼容旧参数:1成功 2失败
+ DateFrom string // 起始时间(yyyy-MM-dd HH:mm:ss)
+ DateTo string // 结束时间
+ OrderBy string // createdAt(默认) | durationMs
+ OrderDir string // desc(默认) | asc
+}
+
+// 构造并归一化:Result 为兼容字段,优先级低于 Status。
+func (f *NoticeLogFilter) Normalize() {
+ if f.Page <= 0 {
+ f.Page = 1
+ }
+ if f.Size <= 0 {
+ f.Size = 10
+ }
+ if f.Size > 100 {
+ f.Size = 100
+ }
+ if f.Status == 0 && f.Result > 0 {
+ f.Status = f.Result
+ }
+ if f.OrderDir != "asc" {
+ f.OrderDir = "desc"
+ }
+ if f.OrderBy != "durationMs" {
+ f.OrderBy = "createdAt"
+ }
}
// RenderTemplate 简单模板渲染:将 {{key}} 替换为 vars 中对应值。
diff --git a/internal/model/dto/notice_meta.go b/internal/model/dto/notice_meta.go
new file mode 100644
index 0000000..9f10dc1
--- /dev/null
+++ b/internal/model/dto/notice_meta.go
@@ -0,0 +1,312 @@
+// Package dto 通知模块的枚举字典:渠道 / 事件 / 类型 / 分组。
+// 说明:通知历史记录需要展示「渠道、类型、分组」等中文名称,
+// 这里集中维护编码→名称映射,前端下拉选项也由 OptionList 统一下发,避免前后端各写一份。
+package dto
+
+// NoticeMetaItem 通知字典项(编码 + 名称)。
+type NoticeMetaItem struct {
+ Code string `json:"code"`
+ Name string `json:"name"`
+}
+
+// ---------------- 渠道 ----------------
+
+// 渠道编码常量(与 notice_channel.code 对应)。
+const (
+ NoticeChannelBark = "bark" // Bark(iOS 推送)
+ NoticeChannelPushplus = "pushplus" // pushplus(微信推送)
+ NoticeChannelWebhook = "webhook" // 通用 Webhook
+ NoticeChannelEmail = "email" // 邮件
+ NoticeChannelInternal = "internal" // 站内消息
+)
+
+// channelNames 渠道编码 → 中文名称。
+var channelNames = map[string]string{
+ NoticeChannelBark: "Bark",
+ NoticeChannelPushplus: "pushplus",
+ NoticeChannelWebhook: "Webhook",
+ NoticeChannelEmail: "邮件",
+ NoticeChannelInternal: "站内消息",
+}
+
+// NoticeChannelName 渠道编码转中文名称(未知编码原样返回)。
+func NoticeChannelName(code string) string {
+ if code == "" {
+ return ""
+ }
+ if name, ok := channelNames[code]; ok {
+ return name
+ }
+ return code
+}
+
+// NoticeChannelOptions 渠道选项(前端下拉)。
+func NoticeChannelOptions() []NoticeMetaItem {
+ return []NoticeMetaItem{
+ {Code: NoticeChannelBark, Name: channelNames[NoticeChannelBark]},
+ {Code: NoticeChannelPushplus, Name: channelNames[NoticeChannelPushplus]},
+ {Code: NoticeChannelWebhook, Name: channelNames[NoticeChannelWebhook]},
+ {Code: NoticeChannelEmail, Name: channelNames[NoticeChannelEmail]},
+ {Code: NoticeChannelInternal, Name: channelNames[NoticeChannelInternal]},
+ }
+}
+
+// ---------------- 事件类型 ----------------
+
+// 事件类型常量(与 notice_rule.event_type / notice_log.event_type 对应)。
+const (
+ NoticeEventJobDone = "job_done" // 自动任务执行成功
+ NoticeEventJobFail = "job_fail" // 自动任务执行失败
+ NoticeEventSecurityAlert = "security_alert" // 服务器安全告警
+ NoticeEventRecruitDaily = "recruit_daily" // 招聘公告每日推送
+ NoticeEventRecruitCrawl = "recruit_crawl" // 招聘公告抓取结果
+ NoticeEventTest = "test" // 手动测试发送
+ NoticeEventManual = "manual" // 后台手动触发
+)
+
+// eventNames 事件编码 → 中文名称。
+var eventNames = map[string]string{
+ NoticeEventJobDone: "任务完成",
+ NoticeEventJobFail: "任务失败",
+ NoticeEventSecurityAlert: "安全告警",
+ NoticeEventRecruitDaily: "招聘每日推送",
+ NoticeEventRecruitCrawl: "招聘抓取",
+ NoticeEventTest: "测试发送",
+ NoticeEventManual: "手动触发",
+}
+
+// NoticeEventName 事件编码转中文名称(未知编码原样返回)。
+func NoticeEventName(code string) string {
+ if code == "" {
+ return ""
+ }
+ if name, ok := eventNames[code]; ok {
+ return name
+ }
+ return code
+}
+
+// NoticeEventOptions 事件选项(前端下拉)。
+func NoticeEventOptions() []NoticeMetaItem {
+ return []NoticeMetaItem{
+ {Code: NoticeEventJobDone, Name: eventNames[NoticeEventJobDone]},
+ {Code: NoticeEventJobFail, Name: eventNames[NoticeEventJobFail]},
+ {Code: NoticeEventSecurityAlert, Name: eventNames[NoticeEventSecurityAlert]},
+ {Code: NoticeEventRecruitDaily, Name: eventNames[NoticeEventRecruitDaily]},
+ {Code: NoticeEventRecruitCrawl, Name: eventNames[NoticeEventRecruitCrawl]},
+ {Code: NoticeEventTest, Name: eventNames[NoticeEventTest]},
+ {Code: NoticeEventManual, Name: eventNames[NoticeEventManual]},
+ }
+}
+
+// ---------------- 通知类型(按事件大类归拢) ----------------
+
+// 通知类型编码(由事件类型推导,用于「类型」维度筛选与展示)。
+const (
+ NoticeTypeJob = "job" // 任务通知
+ NoticeTypeSecurity = "security" // 安全告警
+ NoticeTypeRecruit = "recruit" // 招聘推送
+ NoticeTypeTest = "test" // 测试通知
+ NoticeTypeManual = "manual" // 手动通知
+ NoticeTypeSystem = "system" // 其他/系统
+)
+
+// typeNames 类型编码 → 中文名称。
+var typeNames = map[string]string{
+ NoticeTypeJob: "任务通知",
+ NoticeTypeSecurity: "安全告警",
+ NoticeTypeRecruit: "招聘推送",
+ NoticeTypeTest: "测试通知",
+ NoticeTypeManual: "手动通知",
+ NoticeTypeSystem: "系统通知",
+}
+
+// eventTypes 事件编码 → 归属类型编码。
+var eventTypes = map[string]string{
+ NoticeEventJobDone: NoticeTypeJob,
+ NoticeEventJobFail: NoticeTypeJob,
+ NoticeEventSecurityAlert: NoticeTypeSecurity,
+ NoticeEventRecruitDaily: NoticeTypeRecruit,
+ NoticeEventRecruitCrawl: NoticeTypeRecruit,
+ NoticeEventTest: NoticeTypeTest,
+ NoticeEventManual: NoticeTypeManual,
+}
+
+// NoticeTypeName 类型编码转中文名称(未知编码原样返回)。
+func NoticeTypeName(code string) string {
+ if code == "" {
+ return ""
+ }
+ if name, ok := typeNames[code]; ok {
+ return name
+ }
+ return code
+}
+
+// NoticeEventToType 由事件编码推导类型编码(未知归入 system)。
+func NoticeEventToType(eventType string) string {
+ if t, ok := eventTypes[eventType]; ok {
+ return t
+ }
+ return NoticeTypeSystem
+}
+
+// NoticeTypeOptions 类型选项(前端下拉)。
+func NoticeTypeOptions() []NoticeMetaItem {
+ return []NoticeMetaItem{
+ {Code: NoticeTypeJob, Name: typeNames[NoticeTypeJob]},
+ {Code: NoticeTypeSecurity, Name: typeNames[NoticeTypeSecurity]},
+ {Code: NoticeTypeRecruit, Name: typeNames[NoticeTypeRecruit]},
+ {Code: NoticeTypeTest, Name: typeNames[NoticeTypeTest]},
+ {Code: NoticeTypeManual, Name: typeNames[NoticeTypeManual]},
+ {Code: NoticeTypeSystem, Name: typeNames[NoticeTypeSystem]},
+ }
+}
+
+// NoticeTypeEventCodes 类型编码 → 该类型下所有事件编码(用于按类型筛选日志)。
+// 注意:system 为兜底类型,无固定事件列表,需配合 KnownEventCodes 反向排除。
+func NoticeTypeEventCodes(noticeType string) []string {
+ if noticeType == "" {
+ return nil
+ }
+ out := make([]string, 0, len(eventTypes))
+ for event, t := range eventTypes {
+ if t == noticeType {
+ out = append(out, event)
+ }
+ }
+ return out
+}
+
+// ---------------- 通知分组 ----------------
+
+// 分组编码(业务归属分组,用于「分组」维度筛选与展示)。
+const (
+ NoticeGroupAutoJob = "auto_job" // 自动任务
+ NoticeGroupServer = "server" // 服务器
+ NoticeGroupRecruitment = "recruitment" // 招聘
+ NoticeGroupSystem = "system" // 系统
+)
+
+// groupNames 分组编码 → 中文名称。
+var groupNames = map[string]string{
+ NoticeGroupAutoJob: "自动任务",
+ NoticeGroupServer: "服务器监控",
+ NoticeGroupRecruitment: "招聘中心",
+ NoticeGroupSystem: "系统管理",
+}
+
+// groupTypes 分组编码 → 包含的通知类型。
+var groupTypes = map[string]string{
+ NoticeGroupAutoJob: NoticeTypeJob,
+ NoticeGroupServer: NoticeTypeSecurity,
+ NoticeGroupRecruitment: NoticeTypeRecruit,
+ NoticeGroupSystem: NoticeTypeSystem,
+}
+
+// NoticeGroupName 分组编码转中文名称(未知编码原样返回)。
+func NoticeGroupName(code string) string {
+ if code == "" {
+ return ""
+ }
+ if name, ok := groupNames[code]; ok {
+ return name
+ }
+ return code
+}
+
+// NoticeTypeToGroup 由类型编码推导分组编码。
+func NoticeTypeToGroup(noticeType string) string {
+ switch noticeType {
+ case NoticeTypeJob:
+ return NoticeGroupAutoJob
+ case NoticeTypeSecurity:
+ return NoticeGroupServer
+ case NoticeTypeRecruit:
+ return NoticeGroupRecruitment
+ }
+ return NoticeGroupSystem
+}
+
+// NoticeEventToGroup 由事件编码推导分组编码。
+func NoticeEventToGroup(eventType string) string {
+ return NoticeTypeToGroup(NoticeEventToType(eventType))
+}
+
+// NoticeGroupOptions 分组选项(前端下拉)。
+func NoticeGroupOptions() []NoticeMetaItem {
+ return []NoticeMetaItem{
+ {Code: NoticeGroupAutoJob, Name: groupNames[NoticeGroupAutoJob]},
+ {Code: NoticeGroupServer, Name: groupNames[NoticeGroupServer]},
+ {Code: NoticeGroupRecruitment, Name: groupNames[NoticeGroupRecruitment]},
+ {Code: NoticeGroupSystem, Name: groupNames[NoticeGroupSystem]},
+ }
+}
+
+// NoticeGroupEventCodes 分组编码 → 该分组下所有事件编码(用于按分组筛选日志)。
+func NoticeGroupEventCodes(group string) []string {
+ if group == "" {
+ return nil
+ }
+ out := make([]string, 0, len(eventTypes))
+ for event := range eventTypes {
+ if NoticeEventToGroup(event) == group {
+ out = append(out, event)
+ }
+ }
+ return out
+}
+
+// KnownEventCodes 字典中已登记的全部事件编码(用于「系统通知」等兜底维度的反向排除)。
+func KnownEventCodes() []string {
+ out := make([]string, 0, len(eventTypes))
+ for event := range eventTypes {
+ out = append(out, event)
+ }
+ return out
+}
+
+// ---------------- 发送状态 ----------------
+
+// 发送状态常量(notice_log.status)。
+const (
+ NoticeStatusPending = 0 // 待发送(已入队)
+ NoticeStatusSuccess = 1 // 成功
+ NoticeStatusFailed = 2 // 失败
+)
+
+// NoticeStatusName 发送状态码转中文名称。
+func NoticeStatusName(status int) string {
+ switch status {
+ case NoticeStatusPending:
+ return "待发送"
+ case NoticeStatusSuccess:
+ return "成功"
+ case NoticeStatusFailed:
+ return "失败"
+ }
+ return "未知"
+}
+
+// ---------------- 统计 ----------------
+
+// NoticeLogStats 通知历史记录统计(列表页顶部概览)。
+type NoticeLogStats struct {
+ Total int `json:"total"` // 总记录数(按当前筛选条件)
+ Success int `json:"success"` // 成功数
+ Failed int `json:"failed"` // 失败数
+ SuccessRate int `json:"successRate"` // 成功率(百分比,四舍五入)
+}
+
+// NoticeLogStatsFilter 统计入参:与列表筛选保持一致(仅去掉分页维度)。
+// DateField 为空时按 created_at 统计。
+type NoticeLogStatsFilter struct {
+ Keyword string
+ NoticeType string
+ Group string
+ EventType string
+ ChannelCode string
+ Status int
+ DateFrom string
+ DateTo string
+}
diff --git a/internal/model/entity/notice.go b/internal/model/entity/notice.go
index cb25dc1..dd47380 100644
--- a/internal/model/entity/notice.go
+++ b/internal/model/entity/notice.go
@@ -27,19 +27,25 @@ type NoticeRule struct {
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
-// NoticeLog 通知发送记录。
+// NoticeLog 通知发送记录(通知历史记录)。
type NoticeLog struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
+ BatchId string `json:"batchId" orm:"batch_id" description:"批次号(同一次业务触发)"`
RuleId uint64 `json:"ruleId" orm:"rule_id" description:"规则ID"`
- EventType string `json:"eventType" orm:"event_type" description:"事件类型"`
+ EventType string `json:"eventType" orm:"event_type" description:"事件编码"`
ChannelCode string `json:"channelCode" orm:"channel_code" description:"渠道编码"`
UserId uint64 `json:"userId" orm:"user_id" description:"接收用户ID"`
Target string `json:"target" orm:"target" description:"发送目标"`
Title string `json:"title" orm:"title" description:"标题"`
Body string `json:"body" orm:"body" description:"内容"`
- Result int `json:"result" orm:"result" description:"1成功 0失败"`
+ Status int `json:"status" orm:"status" description:"0待发送 1成功 2失败"`
+ Result int `json:"result" orm:"result" description:"1成功 0失败(兼容旧字段)"`
Error string `json:"error" orm:"error" description:"错误信息"`
- CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
+ RetryCount int `json:"retryCount" orm:"retry_count" description:"已重试次数"`
+ DurationMs int `json:"durationMs" orm:"duration_ms" description:"渠道接口耗时(毫秒)"`
+ Source string `json:"source" orm:"source" description:"触发来源 auto/manual/test"`
+ Remark string `json:"remark" orm:"remark" description:"备注"`
+ CreatedAt string `json:"createdAt" orm:"created_at" description:"通知时间"`
}
// AutoJob 自动任务定义。
diff --git a/internal/model/entity/recruitment.go b/internal/model/entity/recruitment.go
index b6de122..53a93e6 100644
--- a/internal/model/entity/recruitment.go
+++ b/internal/model/entity/recruitment.go
@@ -2,6 +2,8 @@
// 字段命名对齐 service 现有约定:json 用驼峰,orm 用下划线。
package entity
+import "github.com/gogf/gf/v2/os/gtime"
+
// RecruitmentInfo 招聘考试公告主表。
type RecruitmentInfo struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
@@ -37,20 +39,24 @@ type Organization struct {
// CrawlSource 数据源配置(插件化爬虫适配器)。
type CrawlSource struct {
- Id uint64 `json:"id" orm:"id" description:"主键"`
- Name string `json:"name" orm:"name" description:"数据源名称"`
- BaseUrl string `json:"baseUrl" orm:"base_url" description:"站点基础URL"`
- SourceType int `json:"sourceType" orm:"source_type" description:"1静态列表 2SPA 3需登录 4附件型"`
- Category int `json:"category" orm:"category" description:"该源默认考试分类"`
- Region string `json:"region" orm:"region" description:"该源默认地区"`
- ListPath string `json:"listPath" orm:"list_path" description:"列表页路径/接口"`
- Config string `json:"config" orm:"config" description:"适配器扩展配置(JSON)"`
- Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"`
- CronExpr string `json:"cronExpr" orm:"cron_expr" description:"调度表达式(可选)"`
- LastSuccessAt string `json:"lastSuccessAt" orm:"last_success_at" description:"最近成功抓取时间"`
- FailCount int `json:"failCount" orm:"fail_count" description:"连续失败次数"`
- CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
- UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
+ Id uint64 `json:"id" orm:"id" description:"主键"`
+ Name string `json:"name" orm:"name" description:"数据源名称"`
+ BaseUrl string `json:"baseUrl" orm:"base_url" description:"站点基础URL"`
+ SourceType int `json:"sourceType" orm:"source_type" description:"1静态列表 2SPA 3需登录 4附件型"`
+ Category int `json:"category" orm:"category" description:"该源默认考试分类"`
+ Region string `json:"region" orm:"region" description:"该源默认地区"`
+ ListPath string `json:"listPath" orm:"list_path" description:"列表页路径/接口"`
+ Config string `json:"config" orm:"config" description:"适配器扩展配置(JSON)"`
+ Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"`
+ CronExpr string `json:"cronExpr" orm:"cron_expr" description:"调度表达式(可选)"`
+ LastSuccessAt string `json:"lastSuccessAt" orm:"last_success_at" description:"最近成功抓取时间"`
+ FailCount int `json:"failCount" orm:"fail_count" description:"连续失败次数"`
+ LastLogAt *gtime.Time `json:"lastLogAt" orm:"last_log_at" description:"最近一次抓取时间(冗余)"`
+ LastLogFetched int `json:"lastLogFetched" orm:"last_log_fetched" description:"最近一次抓取条数(冗余)"`
+ LastLogNew int `json:"lastLogNew" orm:"last_log_new" description:"最近一次新增条数(冗余)"`
+ LastLogError string `json:"lastLogError" orm:"last_log_error" description:"最近一次错误信息(冗余)"`
+ CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
+ UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// CrawlLog 抓取任务运行日志。
diff --git a/internal/service/notice/notice.go b/internal/service/notice/notice.go
index da0cd81..16c54a2 100644
--- a/internal/service/notice/notice.go
+++ b/internal/service/notice/notice.go
@@ -1,4 +1,4 @@
-// Package notice 提供统一通知服务(渠道/规则/发送/日志)。
+// Package notice 提供统一通知服务(渠道/规则/发送/历史记录)。
// 发送实现:Bark 走路径式 key(POST {baseUrl}/{deviceKey} JSON);pushplus 走官方 send 接口。
package notice
@@ -8,17 +8,23 @@ import (
"strings"
"time"
+ "github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
+ "github.com/gogf/gf/v2/util/guid"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/library/response"
+ "service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
+// gdbModel 查询模型别名,避免在多处书写完整包路径。
+type gdbModel = *gdb.Model
+
// INotice 统一通知服务接口。
type INotice interface {
ChannelList(context.Context) ([]dto.NoticeChannelVO, error)
@@ -26,7 +32,11 @@ type INotice interface {
RuleList(context.Context, string) ([]dto.NoticeRuleVO, error)
RuleSave(context.Context, dto.NoticeRuleInput) (uint64, error)
RuleDelete(context.Context, uint64) error
- LogList(context.Context, dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error)
+ // LogList 通知历史记录分页(返回列表、总数与统计概览)。
+ LogList(context.Context, dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, *dto.NoticeLogStats, error)
+ LogDetail(context.Context, uint64) (*dto.NoticeLogVO, error)
+ LogDelete(context.Context, []uint64) (int, error)
+ LogClear(context.Context, int, string, string) (int, error)
Send(context.Context, string, map[string]string) error // 按启用规则发送通知(事件→渠道→人员)
Test(context.Context, uint64, string, string, []uint64, string, string) (bool, string, error)
}
@@ -64,9 +74,9 @@ func (s *notice) ChannelList(ctx context.Context) ([]dto.NoticeChannelVO, error)
}
func (s *notice) ChannelSave(ctx context.Context, in dto.NoticeChannelInput) (uint64, error) {
- data := map[string]interface{}{
- "code": in.Code, "name": in.Name, "enabled": in.Enabled,
- "config": in.Config, "remark": in.Remark,
+ data := do.NoticeChannel{
+ Code: in.Code, Name: in.Name, Enabled: in.Enabled,
+ Config: in.Config, Remark: in.Remark,
}
if in.Id > 0 {
if _, err := dao.NoticeChannel.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
@@ -104,10 +114,10 @@ func (s *notice) RuleList(ctx context.Context, eventType string) ([]dto.NoticeRu
}
func (s *notice) RuleSave(ctx context.Context, in dto.NoticeRuleInput) (uint64, error) {
- data := map[string]interface{}{
- "name": in.Name, "event_type": in.EventType,
- "channel_codes": toJSON(in.ChannelCodes), "user_ids": toJSON(in.UserIds),
- "title_template": in.TitleTemplate, "body_template": in.BodyTemplate, "enabled": in.Enabled,
+ data := do.NoticeRule{
+ Name: in.Name, EventType: in.EventType,
+ ChannelCodes: toJSON(in.ChannelCodes), UserIds: toJSON(in.UserIds),
+ TitleTemplate: in.TitleTemplate, BodyTemplate: in.BodyTemplate, Enabled: in.Enabled,
}
if in.Id > 0 {
if _, err := dao.NoticeRule.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
@@ -129,18 +139,81 @@ func (s *notice) RuleDelete(ctx context.Context, id uint64) error {
return nil
}
-// ---------------- 日志 ----------------
+// ---------------- 历史记录 ----------------
-func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error) {
+// LogList 通知历史记录分页:支持关键字、类型、分组、事件、渠道、接收人、状态、时间范围、排序。
+func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, *dto.NoticeLogStats, error) {
+ f.Normalize()
+ m := s.logQuery(ctx, f)
+ total, err := m.Clone().Count()
+ if err != nil {
+ return nil, 0, nil, gerror.Wrap(err, "统计通知历史记录总数失败")
+ }
+ // 统计概览(成功/失败/成功率),与列表使用同一套筛选条件。
+ stats, err := s.logStats(ctx, f, total)
+ if err != nil {
+ return nil, 0, nil, err
+ }
+ var list []entity.NoticeLog
+ orderField := "created_at"
+ if f.OrderBy == "durationMs" {
+ orderField = "duration_ms"
+ }
+ if f.OrderDir == "asc" {
+ err = m.Clone().Page(f.Page, f.Size).OrderAsc(orderField).OrderAsc("id").Scan(&list)
+ } else {
+ err = m.Clone().Page(f.Page, f.Size).OrderDesc(orderField).OrderDesc("id").Scan(&list)
+ }
+ if err != nil {
+ return nil, 0, nil, gerror.Wrap(err, "查询通知历史记录列表失败")
+ }
+ out := s.buildLogVOs(ctx, list)
+ return out, total, stats, nil
+}
+
+// logQuery 组装历史记录筛选条件(列表与统计共用,保证口径一致)。
+func (s *notice) logQuery(ctx context.Context, f dto.NoticeLogFilter) gdbModel {
m := dao.NoticeLog.Ctx(ctx)
+ if f.Keyword != "" {
+ kw := "%" + f.Keyword + "%"
+ m = m.Where("title LIKE ? OR body LIKE ? OR target LIKE ? OR error LIKE ?", kw, kw, kw, kw)
+ }
if f.EventType != "" {
m = m.Where("event_type", f.EventType)
+ } else if f.Group != "" {
+ if events := dto.NoticeGroupEventCodes(f.Group); len(events) > 0 {
+ m = m.WhereIn("event_type", events)
+ } else {
+ // 未在字典中的分组(如 system)则反向排除已知事件。
+ m = m.WhereNotIn("event_type", dto.KnownEventCodes())
+ }
+ } else if f.NoticeType != "" {
+ switch f.NoticeType {
+ case dto.NoticeTypeSystem:
+ m = m.WhereNotIn("event_type", dto.KnownEventCodes())
+ default:
+ if events := dto.NoticeTypeEventCodes(f.NoticeType); len(events) > 0 {
+ m = m.WhereIn("event_type", events)
+ }
+ }
}
if f.ChannelCode != "" {
m = m.Where("channel_code", f.ChannelCode)
}
- if f.Result > 0 {
- m = m.Where("result", f.Result)
+ if f.UserId > 0 {
+ m = m.Where("user_id", f.UserId)
+ }
+ if f.BatchId != "" {
+ m = m.Where("batch_id", f.BatchId)
+ }
+ if f.Status > 0 {
+ if f.Status == dto.NoticeStatusSuccess {
+ // 成功:status=1 或历史数据 result=1。
+ m = m.Where("(status = 1 OR (status = 0 AND result = 1))")
+ } else {
+ // 失败:status=2 或历史数据 result=0(且非待发送)。
+ m = m.Where("(status = 2 OR (status = 0 AND result = 0))")
+ }
}
if f.DateFrom != "" {
m = m.WhereGTE("created_at", f.DateFrom)
@@ -148,28 +221,181 @@ func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.Noti
if f.DateTo != "" {
m = m.WhereLTE("created_at", f.DateTo)
}
- total, err := m.Clone().Count()
- if err != nil {
- return nil, 0, gerror.Wrap(err, "统计通知日志总数失败")
+ return m
+}
+
+// logStats 统计概览:总数、成功数、失败数、成功率。
+func (s *notice) logStats(ctx context.Context, f dto.NoticeLogFilter, total int) (*dto.NoticeLogStats, error) {
+ stats := &dto.NoticeLogStats{Total: total}
+ successFilter := f
+ successFilter.Status = dto.NoticeStatusSuccess
+ if n, err := s.logQuery(ctx, successFilter).Clone().Count(); err != nil {
+ return nil, gerror.Wrap(err, "统计通知成功数失败")
+ } else {
+ stats.Success = n
}
- var list []entity.NoticeLog
- if err = m.Page(f.Page, f.Size).OrderDesc("id").Scan(&list); err != nil {
- return nil, 0, gerror.Wrap(err, "查询通知日志列表失败")
+ stats.Failed = total - stats.Success
+ if stats.Failed < 0 {
+ stats.Failed = 0
}
+ if total > 0 {
+ stats.SuccessRate = int(float64(stats.Success)*100/float64(total) + 0.5)
+ }
+ return stats, nil
+}
+
+// buildLogVOs 实体转 VO,并批量补齐规则名 / 接收人 等派生字段(避免 N+1)。
+func (s *notice) buildLogVOs(ctx context.Context, list []entity.NoticeLog) []dto.NoticeLogVO {
out := make([]dto.NoticeLogVO, 0, len(list))
- for _, v := range list {
- out = append(out, dto.NoticeLogVO{
- Id: v.Id, RuleId: v.RuleId, EventType: v.EventType, ChannelCode: v.ChannelCode,
- UserId: v.UserId, Target: v.Target, Title: v.Title, Body: v.Body,
- Result: v.Result, Error: v.Error, CreatedAt: v.CreatedAt,
- })
+ if len(list) == 0 {
+ return out
}
- return out, total, nil
+
+ // 批量取规则名称
+ ruleIds := make([]uint64, 0, len(list))
+ userIds := make([]uint64, 0, len(list))
+ seenRule := map[uint64]struct{}{}
+ seenUser := map[uint64]struct{}{}
+ for _, v := range list {
+ if v.RuleId > 0 {
+ if _, ok := seenRule[v.RuleId]; !ok {
+ seenRule[v.RuleId] = struct{}{}
+ ruleIds = append(ruleIds, v.RuleId)
+ }
+ }
+ if v.UserId > 0 {
+ if _, ok := seenUser[v.UserId]; !ok {
+ seenUser[v.UserId] = struct{}{}
+ userIds = append(userIds, v.UserId)
+ }
+ }
+ }
+
+ ruleNames := map[uint64]string{}
+ if len(ruleIds) > 0 {
+ var rules []entity.NoticeRule
+ if err := dao.NoticeRule.Ctx(ctx).WhereIn("id", ruleIds).Scan(&rules); err == nil {
+ for _, r := range rules {
+ ruleNames[r.Id] = r.Name
+ }
+ }
+ }
+ userNames := map[uint64]string{}
+ if len(userIds) > 0 {
+ var users []entity.AdminUser
+ if err := dao.AdminUser.Ctx(ctx).WhereIn("id", userIds).Fields("id, username, nickname").Scan(&users); err == nil {
+ for _, u := range users {
+ name := u.Nickname
+ if name == "" {
+ name = u.Username
+ }
+ userNames[u.Id] = name
+ }
+ }
+ }
+
+ for _, v := range list {
+ out = append(out, toLogVO(v, ruleNames[v.RuleId], userNames[v.UserId]))
+ }
+ return out
+}
+
+// toLogVO 单条实体转 VO(补齐派生名称字段)。
+func toLogVO(v entity.NoticeLog, ruleName, userName string) dto.NoticeLogVO {
+ noticeType := dto.NoticeEventToType(v.EventType)
+ group := dto.NoticeTypeToGroup(noticeType)
+ // status 兼容历史数据:仅有 result 时按 result 推导。
+ status := v.Status
+ if status == 0 && v.Result == 1 {
+ status = dto.NoticeStatusSuccess
+ } else if status == 0 && v.Result == 0 && v.CreatedAt != "" && v.Error != "" {
+ status = dto.NoticeStatusFailed
+ }
+ return dto.NoticeLogVO{
+ Id: v.Id, BatchId: v.BatchId, RuleId: v.RuleId, RuleName: ruleName,
+ EventType: v.EventType, EventName: dto.NoticeEventName(v.EventType),
+ NoticeType: noticeType, TypeName: dto.NoticeTypeName(noticeType),
+ Group: group, GroupName: dto.NoticeGroupName(group),
+ ChannelCode: v.ChannelCode, ChannelName: dto.NoticeChannelName(v.ChannelCode),
+ UserId: v.UserId, UserName: userName, Target: v.Target,
+ Title: v.Title, Body: v.Body,
+ Status: status, StatusName: dto.NoticeStatusName(status),
+ Result: v.Result, Error: v.Error,
+ RetryCount: v.RetryCount, DurationMs: v.DurationMs,
+ Source: v.Source, Remark: v.Remark, CreatedAt: v.CreatedAt,
+ }
+}
+
+// LogDetail 单条历史记录详情。
+func (s *notice) LogDetail(ctx context.Context, id uint64) (*dto.NoticeLogVO, error) {
+ var v entity.NoticeLog
+ if err := dao.NoticeLog.Ctx(ctx).Where("id", id).Scan(&v); err != nil {
+ return nil, gerror.Wrap(err, "查询通知历史详情失败")
+ }
+ if v.Id == 0 {
+ return nil, response.Error(consts.CodeInvalidParam, "通知记录不存在")
+ }
+ ruleName := ""
+ if v.RuleId > 0 {
+ var r entity.NoticeRule
+ _ = dao.NoticeRule.Ctx(ctx).Where("id", v.RuleId).Scan(&r)
+ ruleName = r.Name
+ }
+ userName := ""
+ if v.UserId > 0 {
+ var u entity.AdminUser
+ _ = dao.AdminUser.Ctx(ctx).Where("id", v.UserId).Fields("id, username, nickname").Scan(&u)
+ userName = u.Nickname
+ if userName == "" {
+ userName = u.Username
+ }
+ }
+ vo := toLogVO(v, ruleName, userName)
+ return &vo, nil
+}
+
+// LogDelete 批量删除历史记录,返回删除条数。
+func (s *notice) LogDelete(ctx context.Context, ids []uint64) (int, error) {
+ if len(ids) == 0 {
+ return 0, response.Error(consts.CodeInvalidParam, "请选择要删除的记录")
+ }
+ r, err := dao.NoticeLog.Ctx(ctx).WhereIn("id", ids).Delete()
+ if err != nil {
+ return 0, gerror.Wrap(err, "删除通知历史记录失败")
+ }
+ n, _ := r.RowsAffected()
+ return int(n), nil
+}
+
+// LogClear 清空历史记录:KeepDays>0 时保留最近 N 天,否则按 DateFrom/DateTo 范围删除(都为空则全清)。
+func (s *notice) LogClear(ctx context.Context, keepDays int, dateFrom, dateTo string) (int, error) {
+ m := dao.NoticeLog.Ctx(ctx)
+ switch {
+ case keepDays > 0:
+ cut := gtime.Now().AddDate(0, 0, -keepDays).Format("Y-m-d H:i:s")
+ m = m.WhereLT("created_at", cut)
+ case dateFrom != "" || dateTo != "":
+ if dateFrom != "" {
+ m = m.WhereGTE("created_at", dateFrom)
+ }
+ if dateTo != "" {
+ m = m.WhereLTE("created_at", dateTo)
+ }
+ default:
+ m = m.Where("id > ?", 0) // 全清
+ }
+ r, err := m.Delete()
+ if err != nil {
+ return 0, gerror.Wrap(err, "清空通知历史记录失败")
+ }
+ n, _ := r.RowsAffected()
+ return int(n), nil
}
// ---------------- 发送 ----------------
// Send 按事件类型查启用规则,向每个渠道的每个接收用户发送,并记录日志。
+// 同一次 Send 产生的多条投递共用同一批次号(batchId),便于按批次追溯。
func (s *notice) Send(ctx context.Context, eventType string, vars map[string]string) error {
var rules []entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("event_type", eventType).Where("enabled", 1).Scan(&rules); err != nil {
@@ -178,6 +404,7 @@ func (s *notice) Send(ctx context.Context, eventType string, vars map[string]str
if len(rules) == 0 {
return nil // 未配置规则 = 静默
}
+ batchId := newBatchId()
for _, r := range rules {
channels := parseStrArray(r.ChannelCodes)
userIds := parseUintArray(r.UserIds)
@@ -185,19 +412,36 @@ func (s *notice) Send(ctx context.Context, eventType string, vars map[string]str
body := dto.RenderTemplate(r.BodyTemplate, vars)
for _, channel := range channels {
for _, uid := range userIds {
- s.deliver(ctx, r.Id, eventType, channel, uid, title, body)
+ s.deliver(ctx, deliverInput{
+ BatchId: batchId, RuleId: r.Id, EventType: eventType,
+ Channel: channel, UserId: uid, Title: title, Body: body,
+ Source: "auto",
+ })
}
}
}
return nil
}
-// deliver 单条投递:取用户渠道凭据 → 发送 → 记日志。
-func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel string, userId uint64, title, body string) {
+// deliverInput 单条投递入参。
+type deliverInput struct {
+ BatchId string
+ RuleId uint64
+ EventType string
+ Channel string
+ UserId uint64
+ Title string
+ Body string
+ Source string // auto/manual/test
+ Remark string
+}
+
+// deliver 单条投递:取用户渠道凭据 → 发送 → 记日志(含耗时与来源)。
+func (s *notice) deliver(ctx context.Context, in deliverInput) {
var user entity.AdminUser
- _ = dao.AdminUser.Ctx(ctx).Where("id", userId).Scan(&user)
+ _ = dao.AdminUser.Ctx(ctx).Where("id", in.UserId).Scan(&user)
var target string
- switch channel {
+ switch in.Channel {
case "bark":
target = user.BarkDeviceId
case "pushplus":
@@ -205,15 +449,19 @@ func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel
default:
target = ""
}
- ok, errMsg := s.sendByChannel(ctx, channel, target, title, body)
- result := 0
+ start := time.Now()
+ ok, errMsg := s.sendByChannel(ctx, in.Channel, target, in.Title, in.Body)
+ duration := int(time.Since(start).Milliseconds())
+ status := dto.NoticeStatusFailed
if ok {
- result = 1
+ status = dto.NoticeStatusSuccess
}
- _, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
- "rule_id": ruleId, "event_type": eventType, "channel_code": channel,
- "user_id": userId, "target": target, "title": title, "body": body,
- "result": result, "error": errMsg,
+ _, _ = dao.NoticeLog.Ctx(ctx).Data(do.NoticeLog{
+ BatchId: in.BatchId, RuleId: in.RuleId, EventType: in.EventType,
+ ChannelCode: in.Channel, UserId: in.UserId, Target: target,
+ Title: in.Title, Body: in.Body,
+ Status: status, Result: boolToInt(ok), Error: errMsg,
+ DurationMs: duration, Source: in.Source, Remark: in.Remark,
}).Insert()
}
@@ -249,9 +497,10 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
var out struct {
Code int `json:"code"`
}
- _ = json.Unmarshal(resp.ReadAll(), &out)
+ respBody := resp.ReadAllString()
+ _ = json.Unmarshal([]byte(respBody), &out)
if out.Code != 200 {
- return false, "bark code " + resp.ReadAllString()
+ return false, "bark code " + respBody
}
return true, ""
case "pushplus":
@@ -268,9 +517,10 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
var out struct {
Code int `json:"code"`
}
- _ = json.Unmarshal(resp.ReadAll(), &out)
+ respBody := resp.ReadAllString()
+ _ = json.Unmarshal([]byte(respBody), &out)
if out.Code != 200 {
- return false, "pushplus code " + resp.ReadAllString()
+ return false, "pushplus code " + respBody
}
return true, ""
}
@@ -279,6 +529,7 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
// Test 测试发送:优先按规则;否则手动指定渠道+目标。
func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target string, userIds []uint64, title, body string) (bool, string, error) {
+ batchId := newBatchId()
if ruleId > 0 {
var r entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("id", ruleId).Scan(&r); err != nil || r.Id == 0 {
@@ -286,7 +537,11 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
}
for _, ch := range parseStrArray(r.ChannelCodes) {
for _, uid := range parseUintArray(r.UserIds) {
- s.deliver(ctx, r.Id, r.EventType, ch, uid, title, body)
+ s.deliver(ctx, deliverInput{
+ BatchId: batchId, RuleId: r.Id, EventType: dto.NoticeEventTest,
+ Channel: ch, UserId: uid, Title: title, Body: body,
+ Source: "test", Remark: "按规则测试",
+ })
}
}
return true, "已按规则投递", nil
@@ -295,9 +550,11 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
if channelCode == "" {
return false, "", response.Error(consts.CodeInvalidParam, "请指定渠道")
}
+ userId := uint64(0)
if target == "" && len(userIds) > 0 {
+ userId = userIds[0]
var user entity.AdminUser
- _ = dao.AdminUser.Ctx(ctx).Where("id", userIds[0]).Scan(&user)
+ _ = dao.AdminUser.Ctx(ctx).Where("id", userId).Scan(&user)
if channelCode == "bark" {
target = user.BarkDeviceId
} else if channelCode == "pushplus" {
@@ -307,17 +564,30 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
if target == "" {
return false, "", response.Error(consts.CodeInvalidParam, "目标凭据为空")
}
+ start := time.Now()
ok, msg := s.sendByChannel(ctx, channelCode, target, title, body)
- _, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
- "rule_id": 0, "event_type": "test", "channel_code": channelCode,
- "user_id": 0, "target": target, "title": title, "body": body,
- "result": boolToInt(ok), "error": msg,
+ duration := int(time.Since(start).Milliseconds())
+ status := dto.NoticeStatusFailed
+ if ok {
+ status = dto.NoticeStatusSuccess
+ }
+ _, _ = dao.NoticeLog.Ctx(ctx).Data(do.NoticeLog{
+ BatchId: batchId, RuleId: 0, EventType: dto.NoticeEventTest,
+ ChannelCode: channelCode, UserId: userId, Target: target,
+ Title: title, Body: body,
+ Status: status, Result: boolToInt(ok), Error: msg,
+ DurationMs: duration, Source: "test", Remark: "手动测试",
}).Insert()
return ok, msg, nil
}
// ---------------- 工具 ----------------
+// newBatchId 生成投递批次号(同一次业务触发共用一个)。
+func newBatchId() string {
+ return guid.S()[:16]
+}
+
func parseStrArray(s string) []string {
var out []string
_ = json.Unmarshal([]byte(s), &out)
diff --git a/internal/service/recruitment/bark.go b/internal/service/recruitment/bark.go
index e99e06a..8b5e3c7 100644
--- a/internal/service/recruitment/bark.go
+++ b/internal/service/recruitment/bark.go
@@ -89,13 +89,13 @@ func barkPush(ctx context.Context, deviceKey, title, body string) (bool, string,
func sendDailyDigest(ctx context.Context) {
var subs []entity.PushSubscription
if err := dao.PushSubscription.Ctx(ctx).Where("enabled", 1).Scan(&subs); err != nil {
- g.Log().Errorf(ctx, "load subscriptions failed: %v", err)
+ g.Log().Errorf(ctx, "加载推送订阅失败: %v", err)
return
}
if len(subs) == 0 {
def := defaultSubscription(ctx)
if def == nil {
- g.Log().Infof(ctx, "no subscription and no default bark device key, skip digest")
+ g.Log().Infof(ctx, "无启用订阅且未配置兜底设备密钥,跳过早报推送")
return
}
subs = []entity.PushSubscription{*def}
@@ -125,7 +125,7 @@ func buildDigest(ctx context.Context, sub entity.PushSubscription) (string, stri
}
var list []dto.RecruitmentInfoVO
if err := m.Fields("title, category, region, deadline").OrderDesc("publish_date").Scan(&list); err != nil {
- g.Log().Errorf(ctx, "build digest query failed: %v", err)
+ g.Log().Errorf(ctx, "早报内容查询失败: %v", err)
return "", ""
}
if sub.OnlyNew == 1 && len(list) == 0 {
@@ -171,6 +171,53 @@ func recordPushLog(ctx context.Context, subId uint64, title, body string, ok boo
SubscriptionId: subId, PushAt: gtime.Now(), Title: title, Body: body,
Result: res, Error: msg, CreatedAt: gtime.Now(),
}).Insert(); err != nil {
- g.Log().Errorf(ctx, "write push log failed: %v", err)
+ g.Log().Errorf(ctx, "写入推送日志失败: %v", err)
+ }
+}
+
+// ---------- 数据源告警 ----------
+
+// notifySourceFailing 推送「数据源连续失败」预警(尚未禁用,提示人工关注)。
+func notifySourceFailing(ctx context.Context, src entity.CrawlSource, failCount int, errMsg string) {
+ title := "抓取源预警 · " + src.Name
+ body := fmt.Sprintf("数据源「%s」已连续失败 %d 次,暂未禁用。\n最近错误:%s",
+ src.Name, failCount, truncateStr(errMsg, 200))
+ sendAlert(ctx, title, body)
+}
+
+// notifySourceDisabled 推送「数据源已自动禁用」告警。
+func notifySourceDisabled(ctx context.Context, src entity.CrawlSource, failCount int, errMsg string) {
+ title := "抓取源已禁用 · " + src.Name
+ body := fmt.Sprintf("数据源「%s」连续失败 %d 次,已自动禁用。\n最近错误:%s\n请在后台确认后手动重新启用。",
+ src.Name, failCount, truncateStr(errMsg, 200))
+ sendAlert(ctx, title, body)
+}
+
+// sendAlert 向所有启用订阅(或兜底密钥)发送一条系统告警。
+// 与早报不同,告警不受订阅的地区/分类过滤影响——运维信息应送达全部订阅者。
+func sendAlert(ctx context.Context, title, body string) {
+ keys := make([]string, 0, 4)
+ var subs []entity.PushSubscription
+ if err := dao.PushSubscription.Ctx(ctx).Where("enabled", 1).Scan(&subs); err == nil {
+ for _, s := range subs {
+ if s.DeviceKey != "" {
+ keys = append(keys, s.DeviceKey)
+ }
+ }
+ }
+ // 库内无订阅时回退到环境变量兜底密钥。
+ if len(keys) == 0 {
+ if k := defaultDeviceKey(ctx); k != "" {
+ keys = append(keys, k)
+ }
+ }
+ if len(keys) == 0 {
+ g.Log().Warningf(ctx, "无可用设备密钥,告警未送达:%s", title)
+ return
+ }
+ for _, k := range keys {
+ if ok, msg, _ := barkPush(ctx, k, title, body); !ok {
+ g.Log().Warningf(ctx, "告警推送失败 device=%s: %s", k, msg)
+ }
}
}
diff --git a/internal/service/recruitment/crawler.go b/internal/service/recruitment/crawler.go
index 4632142..f1ca14e 100644
--- a/internal/service/recruitment/crawler.go
+++ b/internal/service/recruitment/crawler.go
@@ -70,7 +70,12 @@ func crawlerRun(ctx context.Context, src entity.CrawlSource, force bool) dto.Cra
}
// genericStaticCrawl 通用静态列表抓取:取列表页锚点 → 逐条抓详情 → 抽取字段。
+//
+// 抓取行为(增量窗口、详情上限、限速、词表、阈值)全部来自 crawl_source.config,
+// 未配置则走内置默认值,保证历史数据无需迁移即可继续工作。
func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
+ cfg := parseSourceConfig(src.Config)
+
listURL := joinURL(src.BaseUrl, src.ListPath)
if listURL == "" {
listURL = src.BaseUrl
@@ -79,32 +84,39 @@ func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool)
if err != nil {
return nil, err
}
- g.Log().Warningf(ctx, "[recruit-debug] src=%d listURL=%s htmlLen=%d", src.Id, listURL, len(html))
raw := extractAnchors(html, listURL)
- anchors := filterArticleAnchors(raw)
- g.Log().Warningf(ctx, "[recruit-debug] src=%d rawAnchors=%d filtered=%d", src.Id, len(raw), len(anchors))
+ anchors := filterArticleAnchors(raw, cfg)
+ g.Log().Debugf(ctx, "抓取列表页 src=%d url=%s 锚点=%d 选中=%d", src.Id, listURL, len(raw), len(anchors))
+
+ limit := cfg.maxItems(force)
+ incrDays := cfg.incrDays()
var out []dto.RecruitmentInput
- limit := 60
- if force {
- limit = 300 // 全量回溯放宽上限
- }
+ dateMissing := 0 // 未能识别发布日期的条数,用于在日志中告警
+
for _, a := range anchors {
if len(out) >= limit {
break
}
+ if cfg.DelayMs > 0 {
+ // 限速:政府站对高频访问敏感,按源配置间隔。
+ time.Sleep(time.Duration(cfg.DelayMs) * time.Millisecond)
+ }
dHtml, e := fetchHTML(ctx, a.URL)
if e != nil {
continue
}
- title, content, pub, dl, ex := extractDetail(dHtml)
+ title, content, pub, dl, ex := extractDetail(dHtml, cfg)
if title == "" {
title = a.Text
}
pubDate := normalizeDate(pub)
- // 增量模式:跳过 30 天前的公告,避免无效回扫。
+ if pubDate == "" {
+ dateMissing++
+ }
+ // 增量模式:跳过窗口期之外的历史公告,避免无效回扫。force 时忽略窗口。
if !force && pubDate != "" {
if t, e2 := time.Parse("2006-01-02", pubDate); e2 == nil {
- if time.Since(t) > 30*24*time.Hour {
+ if time.Since(t) > time.Duration(incrDays)*24*time.Hour {
continue
}
}
@@ -123,6 +135,10 @@ func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool)
Status: 0,
})
}
+ if dateMissing > 0 {
+ // 发布日期识别失败会影响增量判断与趋势统计,需显式暴露而非静默。
+ g.Log().Warningf(ctx, "源 %d 有 %d 条未识别发布日期,已置空(建议检查 datePatterns 配置)", src.Id, dateMissing)
+ }
return out, nil
}
@@ -145,6 +161,7 @@ func spaCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.Re
if len(extractAnchors(html, listURL)) == 0 {
return nil, gerror.New("SPA 无服务端渲染内容,需接入底层 JSON 接口(阶段0 POC)")
}
+ // 有服务端渲染锚点时,退回通用静态抓取(内部会读取 config 适配)。
return genericStaticCrawl(ctx, src, force)
}
@@ -193,12 +210,13 @@ func walkJSON(node any, out *[]dto.RecruitmentInput) {
// 而 Go 客户端可能被 TLS 指纹级 WAF 拦截返回无内容挑战页),失败再回退 Go 客户端。
func fetchHTML(ctx context.Context, u string) (string, error) {
if body, e := fetchWithCurl(u); e == nil && looksLikeHtml(body) && len(body) >= 3000 {
- g.Log().Warningf(ctx, "[recruit-debug] curl ok len=%d url=%s", len(body), u)
+ g.Log().Debugf(ctx, "curl 抓取成功 len=%d url=%s", len(body), u)
return body, nil
} else if e != nil {
- g.Log().Warningf(ctx, "[recruit-debug] curl failed: %v url=%s", e, u)
+ // curl 不可用(如容器未装 curl)或执行失败,属预期回退场景,用 Debug 记录。
+ g.Log().Debugf(ctx, "curl 抓取未命中,回退 Go 客户端: %v url=%s", e, u)
} else {
- g.Log().Warningf(ctx, "[recruit-debug] curl suspicious len=%d url=%s", len(body), u)
+ g.Log().Debugf(ctx, "curl 返回内容可疑(len=%d),回退 Go 客户端 url=%s", len(body), u)
}
client := g.Client()
client.SetTimeout(15 * time.Second)
@@ -215,10 +233,10 @@ func fetchHTML(ctx context.Context, u string) (string, error) {
}
body := resp.ReadAllString()
if !looksLikeHtml(body) || len(body) < 3000 {
- g.Log().Warningf(ctx, "[recruit-debug] go-fallback suspicious len=%d url=%s", len(body), u)
- return "", gerror.Newf("页面内容可疑(长度=%d)%s", len(body), u)
+ // Go 客户端被 TLS 指纹级 WAF 拦截时会返回无内容的挑战页,这里显式报错便于排查。
+ return "", gerror.Newf("页面内容可疑(长度=%d,疑似被 WAF 拦截)%s", len(body), u)
}
- g.Log().Warningf(ctx, "[recruit-debug] go-fallback ok len=%d url=%s", len(body), u)
+ g.Log().Debugf(ctx, "Go 客户端抓取成功 len=%d url=%s", len(body), u)
return body, nil
}
@@ -262,8 +280,24 @@ var (
dateRe = regexp.MustCompile(`(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})`)
inlineJSONRe = regexp.MustCompile(`(?is)(?:__NEXT_DATA__|__INITIAL_STATE__|window\.__[A-Z_]+)\s*=\s*(\{[\s\S]*?\})\s*;?`)
spaceRe = regexp.MustCompile(`\s+`)
- // recruitKw 招聘类公告常见语义词,用于从政府站链接中识别公告条目。
- recruitKw = regexp.MustCompile(`招聘|招考|公招|选聘|引进|人才|公告|公示|录用|录取|面试|笔试|报名|选调|遴选|拟聘|聘用|招募|招录|考试|考录|体检|考察|资格复审|递补`)
+ // yearPathRe 匹配 URL 路径中的年份(如 /2026/ 或 /202609/),用于判断内容页形态。
+ yearPathRe = regexp.MustCompile(`/(20\d{2})[/_]?(\d{2})?/`)
+ // imgAltRe 抽取
![]()
的 alt 或 title 属性,用于图片型锚点的文本回退。
+ imgAltRe = regexp.MustCompile(`(?i)\b(?:alt|title)\s*=\s*["']([^"']+)["']`)
+ // imageFileRe 判断字符串是否为图片文件名(如 ysqgk3.png、banner.jpg)。
+ imageFileRe = regexp.MustCompile(`(?i)^[\w\-.\s]+\.(?:png|jpe?g|gif|bmp|webp|svg|ico)$`)
+
+ // metaDateRes 发布日期标准元数据,按可靠性排序。
+ // 政府站多用 PubDate / publishdate,新闻系统多用 og:published_time / article:published_time。
+ metaDateRes = []*regexp.Regexp{
+ regexp.MustCompile(`(?i)
]+name=["']?(?:PubDate|publishdate|publish_date|Pubdate)["']?[^>]+content=["']([^"']+)["']`),
+ regexp.MustCompile(`(?i)
]+content=["']([^"']+)["'][^>]+name=["']?(?:PubDate|publishdate|publish_date)["']?`),
+ regexp.MustCompile(`(?i)
]+property=["']?(?:og:published_time|article:published_time|og:release_date)["']?[^>]+content=["']([^"']+)["']`),
+ regexp.MustCompile(`(?i)
]+content=["']([^"']+)["'][^>]+property=["']?(?:og:published_time|article:published_time)["']?`),
+ }
+ // dateContainerRe 语义容器内的日期(如 class="time|date|pubdate|info|source" 的块)。
+ dateContainerRe = regexp.MustCompile(
+ `(?is)<(?:div|span|p|td)[^>]*class=["'][^"']*\b(?:time|date|pubdate|publishtime|info|source)\b[^"']*["'][^>]*>([\s\S]{0,120}?)<\/(?:div|span|p|td)>`)
)
// extractAnchors 从 HTML 抽取绝对化后的锚点(URL + 文本)。
@@ -281,15 +315,27 @@ func extractAnchors(html, base string) []anchor {
strings.HasPrefix(href, "#") || strings.HasPrefix(href, "mailto:") {
continue
}
- inner := strings.TrimSpace(stripTags(block[1]))
+ // 注意:stripTags 会把
![]()
替换为空白,图片锚点(如仅含
+ //

)经去标签后 inner 为空,
+ // 随后的 title 属性回退会取到图片文件名,从而绕过语义词表。
+ // 故先尝试用 img 的 alt 作为文本,再回退
的 title,最后统一做
+ // 「图片文件名」筛除,避免这类噪声进入评分。
+ inner := strings.TrimSpace(collapseSpace(stripTags(block[1])))
text := inner
- if tm := titleAttrRe.FindStringSubmatch(full); len(tm) >= 2 {
- t := strings.TrimSpace(tm[1])
- if len([]rune(text)) < 4 && t != "" {
- text = t
+ if text == "" || isImageFileName(text) {
+ text = firstAttr(block[1], imgAltRe)
+ }
+ // 文本过短时回退到 的 title 属性(政府站常把真实标题放这里)。
+ if len([]rune(text)) < 4 {
+ if tm := titleAttrRe.FindStringSubmatch(full); len(tm) >= 2 {
+ t := strings.TrimSpace(collapseSpace(tm[1]))
+ if t != "" {
+ text = t
+ }
}
}
- if text == "" {
+ // 最终筛除:图片文件名不是有效标题(其 alt/title 也常是文件名)。
+ if text == "" || isImageFileName(text) {
continue
}
abs := resolveURL(base, href)
@@ -302,23 +348,22 @@ func extractAnchors(html, base string) []anchor {
}
// filterArticleAnchors 过滤导航/无用链接,保留疑似招聘公告条目并去重。
-func filterArticleAnchors(as []anchor) []anchor {
+//
+// 采用评分制(见 keywords.go 的 scoreAnchor):不再要求 URL 与文本「同时」命中,
+// 从而修复「补充工作人员的通知」「公开选调简章」等标题变体被漏抓的问题。
+// 词表与阈值来自数据源配置(crawl_source.config),未配置则用内置默认。
+func filterArticleAnchors(as []anchor, cfg SourceConfig) []anchor {
+ incWords := cfg.includeWordsOrDefault()
+ excWords := cfg.excludeWordsOrDefault()
+ minScore := cfg.minScore()
+
out := make([]anchor, 0, len(as))
for _, a := range as {
text := strings.TrimSpace(a.Text)
if len([]rune(text)) < 4 {
continue
}
- u := strings.ToLower(a.URL)
- // URL 形态线索:静态详情页、政务公开/人事招考栏目、含年份。
- urlHint := strings.Contains(u, ".html") || strings.Contains(u, ".shtml") ||
- strings.Contains(u, ".php") || strings.Contains(u, "/tzgg") ||
- strings.Contains(u, "/rszk") || strings.Contains(u, "/rsxx") ||
- strings.Contains(u, "/zfxx") || dateRe.MatchString(a.URL)
- // 文本语义线索:政府站公告标题常含这些词(必须命中,过滤"市长信箱/重点领域"等非招聘页)。
- textHint := recruitKw.MatchString(text) || recruitKw.MatchString(a.URL)
- // 同时满足"内容页形态"与"招聘语义",避免误抓栏目/互动页。
- if urlHint && textHint {
+ if scoreAnchor(a, incWords, excWords, minScore) {
out = append(out, a)
}
}
@@ -334,17 +379,26 @@ func filterArticleAnchors(as []anchor) []anchor {
return uniq
}
-// extractDetail 从详情页抽取标题/正文/发布日期/报名截止/笔试日期(启发式,可按源调优)。
-func extractDetail(html string) (title, content, publishDate, deadline, examDate string) {
+// extractDetail 从详情页抽取标题/正文/发布日期/报名截止/笔试日期。
+//
+// 发布日期采用「分段抽取」策略(修复原 firstDate 取整页首个日期、误把
+// 页面头部"今天是2026年9月13日"当作发布日的问题):
+// 1. 优先 的 PubDate / publishdate / og:published_time 等标准字段;
+// 2. 其次带语义容器的日期(如 class="time|date|pubdate|info" 的块);
+// 3. 再次「发布时间:/发布日期:/日期:」等中文前缀后的日期;
+// 4. 自定义正则(config.datePatterns,按源覆盖);
+// 5. 全部未命中则置空(不再盲目取整页第一个日期)。
+func extractDetail(html string, cfg SourceConfig) (title, content, publishDate, deadline, examDate string) {
if m := titleRe.FindStringSubmatch(html); len(m) > 1 {
- title = stripTags(m[1])
+ title = cleanText(m[1])
}
- if title == "" {
+ if strings.TrimSpace(title) == "" {
if m := h1Re.FindStringSubmatch(html); len(m) > 1 {
- title = stripTags(m[1])
+ title = cleanText(m[1])
}
}
title = strings.TrimSpace(title)
+
body := scriptRe.ReplaceAllString(html, " ")
body = styleRe.ReplaceAllString(body, " ")
text := stripTags(body)
@@ -353,19 +407,76 @@ func extractDetail(html string) (title, content, publishDate, deadline, examDate
text = string([]rune(text)[:4000])
}
content = text
- publishDate = normalizeDate(firstDate(html))
- deadline = normalizeDate(findDateAfter(text, "报名"))
- examDate = normalizeDate(findDateAfter(text, "笔试"))
+
+ publishDate = extractPublishDate(html, text, cfg)
+ // 截止/笔试日期在正文中按关键词就近查找:
+ // 政府公告常写「报名时间:2026年9月1日9:00至9月5日17:00」,
+ // 故限制关键词后 40 字内取证,避免取到正文别处的无关日期。
+ deadline = normalizeDate(findDateAfterLimit(text, "报名", 40))
+ examDate = normalizeDate(findDateAfterLimit(text, "笔试", 40))
return
}
-// firstDate 返回文本中首个日期(归一化后)。
-func firstDate(s string) string {
- m := dateRe.FindStringSubmatch(s)
+// extractPublishDate 按优先级分段抽取发布日期,全部未命中返回空串。
+func extractPublishDate(html, text string, cfg SourceConfig) string {
+ // 1. meta 标准字段(最可靠)。
+ for _, re := range metaDateRes {
+ if m := re.FindStringSubmatch(html); len(m) > 1 {
+ if d := normalizeDate(m[1]); d != "" {
+ return d
+ }
+ }
+ }
+ // 2. 语义容器内的日期(如 2026-09-13
)。
+ if m := dateContainerRe.FindStringSubmatch(html); len(m) > 1 {
+ if d := normalizeDate(m[1]); d != "" {
+ return d
+ }
+ }
+ // 3. 中文前缀后的日期(发布时间:/发布日期:/日期:/时间:)。
+ for _, kw := range []string{"发布时间", "发布日期", "发布于", "日期", "时间"} {
+ if d := normalizeDate(findDateAfter(text, kw)); d != "" {
+ // 仅取关键词后 40 字内出现的日期,避免跨度太大误取。
+ if v := findDateAfterLimit(text, kw, 40); v != "" {
+ return normalizeDate(v)
+ }
+ }
+ }
+ // 4. 源自定义正则。
+ for _, p := range cfg.DatePatterns {
+ if p == "" {
+ continue
+ }
+ re, err := regexp.Compile(p)
+ if err != nil {
+ continue
+ }
+ if m := re.FindStringSubmatch(html); len(m) > 1 {
+ if d := normalizeDate(m[1]); d != "" {
+ return d
+ }
+ }
+ }
+ // 5. 未识别:置空并在调用方统一告警(避免误取页面头部日期)。
+ return ""
+}
+
+// findDateAfterLimit 在 keyword 之后 limit 个字符内查找日期,超出范围视为未命中。
+func findDateAfterLimit(text, keyword string, limit int) string {
+ idx := strings.Index(text, keyword)
+ if idx < 0 {
+ return ""
+ }
+ rest := text[idx:]
+ runes := []rune(rest)
+ if len(runes) > limit {
+ runes = runes[:limit]
+ }
+ m := dateRe.FindStringSubmatch(string(runes))
if len(m) == 0 {
return ""
}
- return normalizeDate(m[0])
+ return m[0]
}
// findDateAfter 在 keyword 之后查找下一个日期(用于报名/笔试时间)。
@@ -406,6 +517,30 @@ func collapseSpace(s string) string {
return spaceRe.ReplaceAllString(s, " ")
}
+// cleanText 清理文本中的标签残留与首尾空白,用于标题等展示字段。
+// 政府站的锚点内文本常含换行与制表符(如 "\n\t\t\t人事招考"),需归一化。
+func cleanText(s string) string {
+ return strings.TrimSpace(collapseSpace(stripTags(s)))
+}
+
+// isImageFileName 判断文本是否为图片文件名(图片型锚点的特征)。
+func isImageFileName(s string) bool {
+ s = strings.TrimSpace(s)
+ if s == "" || strings.ContainsAny(s, "\n") {
+ return false
+ }
+ return imageFileRe.MatchString(s)
+}
+
+// firstAttr 用正则从 HTML 片段中抽取首个属性值(如 img 的 alt/title)。
+func firstAttr(html string, re *regexp.Regexp) string {
+ m := re.FindStringSubmatch(html)
+ if len(m) < 2 {
+ return ""
+ }
+ return strings.TrimSpace(collapseSpace(m[1]))
+}
+
// resolveURL 将相对链接解析为绝对 URL。
func resolveURL(base, href string) string {
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
@@ -563,11 +698,20 @@ func nullIfEmpty(s string) any {
return s
}
-// recordCrawlLog 写抓取日志并更新数据源运行状态(成功清失败计数;失败累加)。
+// autoDisableThreshold 连续失败达该次数后自动禁用数据源,避免无效空跑。
+// 采用「连续」语义:中间任意一次成功即归零,避免偶发网络抖动误禁。
+const autoDisableThreshold = 5
+
+// autoDisableWarnThreshold 连续失败达该次数时先推送一次预警(尚未禁用)。
+const autoDisableWarnThreshold = 3
+
+// recordCrawlLog 写抓取日志并更新数据源运行状态。
+// - 成功:清失败计数、更新 LastSuccessAt 与最近运行摘要冗余列;
+// - 失败:累加失败计数,达阈值自动禁用并推送告警。
func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
errMsg := ""
if res.Err != nil {
- // 错误可能含整页 SQL,截断避免超过 error 列长度导致日志也写不进。
+ // 错误可能含整页内容,截断避免超过 error 列长度导致日志也写不进。
errMsg = res.Err.Error()
const maxErr = 900
if len(errMsg) > maxErr {
@@ -580,15 +724,54 @@ func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
}).Insert(); err != nil {
g.Log().Errorf(ctx, "写入抓取日志失败: %v", err)
}
- if res.Err != nil {
- var src entity.CrawlSource
- _ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Scan(&src)
- _, _ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).
- Data(do.CrawlSource{FailCount: src.FailCount + 1}).Update()
+
+ // 读取当前源状态,用于累计失败次数与判断是否需自动禁用。
+ var src entity.CrawlSource
+ if err := dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Scan(&src); err != nil {
+ if !strings.Contains(err.Error(), "no rows") {
+ g.Log().Errorf(ctx, "查询数据源状态失败: %v", err)
+ }
return
}
- _, _ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).
- Data(do.CrawlSource{LastSuccessAt: gtime.Now().String(), FailCount: 0}).Update()
+
+ // 无论成败都同步「最近一次运行摘要」冗余列,供 Sources() 零 JOIN 读取。
+ summary := do.CrawlSource{
+ LastLogAt: gtime.Now(),
+ LastLogFetched: res.Fetched,
+ LastLogNew: res.NewCount,
+ LastLogError: truncateStr(errMsg, 500),
+ }
+
+ if res.Err != nil {
+ failCount := src.FailCount + 1
+ summary.FailCount = failCount
+ // 达阈值自动禁用,防止持续无效空跑并污染日志。
+ if failCount >= autoDisableThreshold && src.Enabled == 1 {
+ summary.Enabled = 0
+ g.Log().Warningf(ctx, "数据源 %d(%s) 连续失败 %d 次,已自动禁用", src.Id, src.Name, failCount)
+ notifySourceDisabled(ctx, src, failCount, errMsg)
+ } else if failCount == autoDisableWarnThreshold {
+ // 首次达预警线时推送一次提醒,便于人工介入。
+ notifySourceFailing(ctx, src, failCount, errMsg)
+ }
+ } else {
+ summary.LastSuccessAt = gtime.Now().String()
+ summary.FailCount = 0
+ }
+ if _, err := dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Data(summary).Update(); err != nil {
+ g.Log().Errorf(ctx, "更新数据源状态失败: %v", err)
+ }
+}
+
+// truncateStr 按最大字符数截断字符串,超长时添加省略标记。
+func truncateStr(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ if n <= 3 {
+ return s[:n]
+ }
+ return s[:n-3] + "..."
}
// ---------- 订阅辅助 ----------
diff --git a/internal/service/recruitment/keywords.go b/internal/service/recruitment/keywords.go
new file mode 100644
index 0000000..25530d7
--- /dev/null
+++ b/internal/service/recruitment/keywords.go
@@ -0,0 +1,170 @@
+// Package recruitment 的链接筛选词表与评分规则。
+//
+// 背景:政府站列表页包含大量导航/栏目/互动链接,需要从中挑出「招聘公告详情页」。
+// 早期实现要求「URL 形态命中」与「文本语义命中」同时成立,导致「补充工作人员的通知」
+// 「公开选调公务员简章」等标题变体被系统性漏抓(且静默无报错)。
+//
+// 现改为评分制:URL 形态加分、标准词加分、排除词大幅减分,达到阈值即入选。
+// 词表与阈值均可通过 crawl_source.config 覆盖(见 source_config.go)。
+package recruitment
+
+import "strings"
+
+// defaultIncludeWords 招聘类公告常见语义词(标准词)。
+// 命中即认为「像一条招聘公告」,覆盖招聘/考试/人才引进/选调遴选等各口径。
+var defaultIncludeWords = []string{
+ "招聘", "招考", "招录", "招募", "公招",
+ "选调", "遴选", "选聘", "聘用", "拟聘",
+ "引进", "人才", "急需紧缺",
+ "公告", "简章", "启事", "通知", "公示",
+ "报名", "笔试", "面试", "体检", "考察", "资格复审", "递补",
+ "考试", "考录", "录用", "录取", "拟录用",
+ "事业单位", "公务员", "国企", "央企", "编制",
+}
+
+// defaultExcludeWords 明确的非公告页面词汇,命中则大幅减分(优先排除)。
+// 这些是政府站常见的固定栏目与工具页,其 URL 形态常与详情页相似,仅靠 URL 无法区分。
+var defaultExcludeWords = []string{
+ "政策解读", "常见问题", "办事指南", "下载中心", "联系我们", "网站地图",
+ "市长信箱", "在线咨询", "意见建议", "问卷调查", "友情链接", "版权声明",
+ "无障碍", "长者版", "繁體", "英文版", "手机版", "返回顶部",
+ "信息公开指南", "依申请公开", "年度报告", "财政预决算", "机构职能",
+ "领导之窗", "内设机构", "直属单位", "RSS",
+ // 以下为实测中被误抓的信息公开类导航页(贵阳市人社局站点)。
+ "政府信息公开制度", "政府信息公开年报", "政府非公开信息目录",
+ "建议提案公开", "重点领域公开", "信息公开目录", "信息公开年报",
+ "公开指南", "公开制度", "预决算公开", "三公经费", "权责清单",
+ // 友情链接 / 外部站点入口(政府站页脚常见,URL 常以 .html 结尾易误判)。
+ "友情链接", "贵阳人才网", "信用中国", "人才服务中心",
+ "官方网站", "政务服务网", "返回", "首页",
+ // 站内智能客服 / AI 助手入口(URL 形如 /ai/{id}/index.html)。
+ "人社小智", "智能客服", "在线客服", "AI助手", "智能问答",
+}
+
+// defaultExcludeHosts 排除的域名关键词——属于「外部链接」而非本站公告。
+// 政府站页脚常挂友链(人才网、信用中国等),其 URL 形态与正文页相似,
+// 仅靠路径无法区分,故按域名做二级过滤。
+var defaultExcludeHosts = []string{
+ "gyrc.cn", // 贵阳人才网
+ "cx.guiyang.gov.cn", // 信用中国(贵州贵阳)
+ "zwfw.guizhou.gov.cn", // 贵州政务服务网
+}
+
+// contentSuffixes 内容页 URL 后缀——真实公告正文的特征。
+//
+// 核心区分逻辑(经实测校准):
+// - 栏目目录页以「/」结尾,如 `/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/`;
+// - 公告正文以文件后缀结尾,如 `.../rszk/202609/t20260908_90834079.html`。
+//
+// 注意:两者共享前半段栏目路径,故**绝不能**按路径段判断,
+// 只能按「结尾形态」区分——这是本模块最易踩的坑。
+var contentSuffixes = []string{".html", ".shtml", ".htm", ".jsp", ".asp", ".php"}
+
+// contentPathHints 内容页路径关键词(部分 CMS 用无后缀的动态路径,如 /art/123456)。
+var contentPathHints = []string{"/art/", "/info/", "/content", "/detail"}
+
+// columnPathHints 栏目目录页的「结尾路径段」——仅当 URL 以此结尾(或以 / 续接)时判为栏目页。
+// 用于压制「人事信息」「重点领域公开」等导航链接,但不误伤嵌在同名栏目下的正文。
+var columnPathHints = []string{
+ "/zfxxgk", "/fdzdgklm", "/zdlygk", "/zffgk", "/xxgkml", "/ysqgk",
+ "/rszk", "/rsxx", "/zwgk", "/tzgg", "/gsgg", "/ksxx", "/zfcg",
+ "/zfxxgkzn", "/zfxxgkzd", "/zfxxgknb", "/zffgkxxml", "/jytagk",
+ "/index", "/list", "/more",
+}
+
+// scoreAnchor 对单个锚点按评分制打分,分数 >= minScore 视为公告正文页。
+//
+// 评分规则(阈值可调,见 source_config.go 的 MinScore):
+// - URL 以内容页后缀结尾(.html 等) +3 ← 最强正向特征
+// - URL 命中动态内容路径(/art/、/info/ 等) +2
+// - URL 含年月日期路径(/2026/09/13/) +1
+// - 文本命中标准词 +2
+// - 文本命中排除词 -6(强排除)
+// - URL 为纯目录(以「/」结尾,无文件后缀) -5 ← 栏目页特征
+// - 文本过短(<6 字) -2
+//
+// 设计取舍:相比原「URL 与文本必须同时命中」的硬条件,评分制允许
+// 「标题不含标准词但 URL 是正文页」的情况入选(修复漏抓),
+// 同时以「结尾形态」压制栏目页噪声(避免误抓导航链接)。
+func scoreAnchor(a anchor, incWords, excWords []string, minScore int) bool {
+ text := strings.TrimSpace(a.Text)
+ lower := strings.ToLower(a.URL)
+ score := 0
+
+ // 0. 域名硬排除:友链/外部站点入口直接判定为非公告(不问评分)。
+ for _, h := range defaultExcludeHosts {
+ if strings.Contains(lower, h) {
+ return false
+ }
+ }
+
+ // 1. 最强正向特征:以内容页后缀结尾。
+ hasContentSuffix := false
+ for _, suf := range contentSuffixes {
+ if strings.HasSuffix(lower, suf) {
+ score += 3
+ hasContentSuffix = true
+ break
+ }
+ }
+ // 2. 动态内容路径(无后缀的 CMS 详情页)。
+ if !hasContentSuffix {
+ for _, p := range contentPathHints {
+ if strings.Contains(lower, p) {
+ score += 2
+ break
+ }
+ }
+ }
+ // 3. URL 含年月日期路径(内容页的常见特征)。
+ if yearPathRe.MatchString(a.URL) || dateRe.MatchString(a.URL) {
+ score++
+ }
+
+ // 4. 文本标准词。
+ if containsAny(text, incWords) {
+ score += 2
+ }
+
+ // 5. 负向:排除词(导航/工具页)。
+ if containsAny(text, excWords) {
+ score -= 6
+ }
+
+ // 6. 负向:纯目录页。判定依据是「以 / 结尾」——此形态必为栏目而非正文。
+ // 同时要求 URL 的末段命中栏目路径词,进一步确保是导航目录。
+ if strings.HasSuffix(lower, "/") {
+ score -= 5
+ } else {
+ for _, p := range columnPathHints {
+ if strings.HasSuffix(lower, p) {
+ score -= 5
+ break
+ }
+ }
+ }
+
+ // 7. 负向:过短文本(「更多/详细/MORE」等分页锚点)。
+ if len([]rune(text)) < 6 {
+ score -= 2
+ }
+
+ return score >= minScore
+}
+
+// containsAny 判断文本是否命中任一词(不区分大小写,兼顾英文词如 RSS)。
+func containsAny(text string, words []string) bool {
+ if text == "" || len(words) == 0 {
+ return false
+ }
+ lower := strings.ToLower(text)
+ for _, w := range words {
+ if w == "" {
+ continue
+ }
+ if strings.Contains(lower, strings.ToLower(w)) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/service/recruitment/keywords_test.go b/internal/service/recruitment/keywords_test.go
new file mode 100644
index 0000000..6148bcb
--- /dev/null
+++ b/internal/service/recruitment/keywords_test.go
@@ -0,0 +1,205 @@
+// Package recruitment 的链接评分规则单元测试。
+//
+// 用贵阳市人社局「人事招考」列表页的真实锚点样本做回归,防止评分规则
+// 调整后再次出现「导航页被误抓」或「真实公告被漏抓」两类问题。
+package recruitment
+
+import "testing"
+
+// TestScoreAnchor 覆盖真实列表页中的典型锚点形态。
+func TestScoreAnchor(t *testing.T) {
+ incWords := defaultIncludeWords
+ excWords := defaultExcludeWords
+ minScore := defaultMinScore
+
+ cases := []struct {
+ name string
+ a anchor
+ want bool // true=应保留(公告正文),false=应过滤(导航/栏目页)
+ }{
+ // ---------- 应保留:真实公告正文 ----------
+ {
+ name: "公告正文-带文件后缀",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202609/t20260908_90834079.html",
+ Text: "2026年贵阳贵安面向退役军人选拔培养中小学教师公告",
+ },
+ want: true,
+ },
+ {
+ name: "公告正文-标题不含标准词但URL是正文",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202603/t20260303_89596144.html",
+ Text: "贵阳贵安2026年公开招聘事业单位工作人员岗位取消情况公告",
+ },
+ want: true,
+ },
+ {
+ name: "公告正文-仅URL可判定",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/202512/t20251225_89089652.html",
+ Text: "关于做好2026年度考试录用公务员工作的通知",
+ },
+ want: true,
+ },
+
+ // ---------- 应过滤:栏目目录页(以 / 结尾) ----------
+ {
+ name: "栏目页-人事招考",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/",
+ Text: "人事招考",
+ },
+ want: false,
+ },
+ {
+ name: "栏目页-重点领域公开",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/fdzdgklmzdlygk/",
+ Text: "重点领域公开",
+ },
+ want: false,
+ },
+ {
+ name: "栏目页-政府非公开信息目录",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zffgkxxml/",
+ Text: "政府非公开信息目录",
+ },
+ want: false,
+ },
+ {
+ name: "栏目页-建议提案公开",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/jytagk/",
+ Text: "建议提案公开",
+ },
+ want: false,
+ },
+ {
+ name: "栏目页-依申请公开",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/ysqgk/index.html",
+ Text: "依申请公开",
+ },
+ want: false,
+ },
+ {
+ name: "导航页-政府信息公开制度",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/zfxxgkzd/",
+ Text: "政府信息 公开制度",
+ },
+ want: false,
+ },
+ {
+ name: "导航页-政府信息公开年报",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/zfxxgknb/",
+ Text: "政府信息 公开年报",
+ },
+ want: false,
+ },
+ {
+ name: "导航页-返回首页",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/",
+ Text: "返回首页",
+ },
+ want: false,
+ },
+ {
+ name: "导航页-机构概况",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkjggk/",
+ Text: "机构概况",
+ },
+ want: false,
+ },
+
+ // ---------- 应过滤:友链/外部站点(URL 以 .html 结尾易误判) ----------
+ {
+ name: "友链-贵阳人才网",
+ a: anchor{
+ URL: "https://www.gyrc.cn/jobPosition/result.html",
+ Text: "贵阳人才网 - 贵阳市人才服务中心官方网站",
+ },
+ want: false,
+ },
+ {
+ name: "友链-信用中国",
+ a: anchor{
+ URL: "http://cx.guiyang.gov.cn/index.html",
+ Text: "信用中国(贵州贵阳)",
+ },
+ want: false,
+ },
+ {
+ name: "工具页-依申请公开",
+ a: anchor{
+ URL: "http://rsj.guiyang.gov.cn/zfxxgk/ysqgk/index.html",
+ Text: "贵阳市人力资源和社会保障局- 依申请公开",
+ },
+ want: false,
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got := scoreAnchor(c.a, incWords, excWords, minScore)
+ if got != c.want {
+ t.Errorf("scoreAnchor() = %v, want %v\n url=%s\n text=%s",
+ got, c.want, c.a.URL, c.a.Text)
+ }
+ })
+ }
+}
+
+// TestCleanText 验证标题清洗:去除标签与折叠空白。
+func TestCleanText(t *testing.T) {
+ cases := []struct{ in, want string }{
+ {"\n\t\t\t人事招考", "人事招考"},
+ {"贵阳贵安 2026 年招聘公告", "贵阳贵安 2026 年招聘公告"},
+ {" 前后空白 ", "前后空白"},
+ }
+ for _, c := range cases {
+ if got := cleanText(c.in); got != c.want {
+ t.Errorf("cleanText(%q) = %q, want %q", c.in, got, c.want)
+ }
+ }
+}
+
+// TestIsImageFileName 验证图片文件名识别(用于剔除图片型锚点噪声)。
+func TestIsImageFileName(t *testing.T) {
+ yes := []string{"ysqgk3.png", "banner.jpg", "logo.gif", "icon.SVG", "pic.webp"}
+ no := []string{"", "2026年招聘公告", "index.html", "关于招聘的通知"}
+ for _, s := range yes {
+ if !isImageFileName(s) {
+ t.Errorf("isImageFileName(%q) = false, want true", s)
+ }
+ }
+ for _, s := range no {
+ if isImageFileName(s) {
+ t.Errorf("isImageFileName(%q) = true, want false", s)
+ }
+ }
+}
+
+// TestExtractAnchorsImageFallback 验证图片型锚点不会以「文件名」形式进入候选。
+// 复现真实页面:
+func TestExtractAnchorsImageFallback(t *testing.T) {
+ html := `
+
+ 
+ `
+ got := extractAnchors(html, "http://rsj.guiyang.gov.cn/")
+ for _, a := range got {
+ if isImageFileName(a.Text) {
+ t.Errorf("图片型锚点应以文件名泄漏进候选: text=%q url=%s", a.Text, a.URL)
+ }
+ }
+ // 该锚点应被完全丢弃(无可读文本)。
+ if len(got) != 0 {
+ t.Errorf("期望丢弃图片型锚点,实际保留 %d 个: %+v", len(got), got)
+ }
+}
diff --git a/internal/service/recruitment/live_diag_test.go b/internal/service/recruitment/live_diag_test.go
new file mode 100644
index 0000000..cb142e2
--- /dev/null
+++ b/internal/service/recruitment/live_diag_test.go
@@ -0,0 +1,39 @@
+package recruitment
+
+import (
+ "os"
+ "testing"
+)
+
+// TestLiveAnchorScoring 诊断:读取本地保存的列表页 HTML,打印每个锚点的评分决策。
+// 用于定位「单元测试判定应过滤、但实际抓取却入库」的差异。
+// 运行前需将列表页 HTML 保存到 testdata/rszk.html。
+func TestLiveAnchorScoring(t *testing.T) {
+ data, err := os.ReadFile("testdata/rszk.html")
+ if err != nil {
+ t.Skipf("跳过:未提供 testdata/rszk.html (%v)", err)
+ }
+ html := string(data)
+ cfg := SourceConfig{} // 模拟源1的配置(无 excludeWords 覆盖)
+ incWords := cfg.includeWordsOrDefault()
+ excWords := cfg.excludeWordsOrDefault()
+ minScore := cfg.minScore()
+
+ t.Logf("词表: include=%d 个, exclude=%d 个, minScore=%d", len(incWords), len(excWords), minScore)
+
+ base := "http://rsj.guiyang.gov.cn/zfxxgk/fdzdgklm/zfxxgkrsxx/rszk/"
+ raw := extractAnchors(html, base)
+ t.Logf("原始锚点=%d", len(raw))
+
+ for i, a := range raw {
+ if len([]rune(a.Text)) < 4 {
+ continue
+ }
+ keep := scoreAnchor(a, incWords, excWords, minScore)
+ // 仅打印与「依申请公开」或判定为 keep 的条目,聚焦问题。
+ if keep || containsAny(a.Text, []string{"依申请公开", "人才网", "信用中国"}) {
+ t.Logf("[%d] keep=%v url=%s", i, keep, a.URL)
+ t.Logf(" text=%q", a.Text)
+ }
+ }
+}
diff --git a/internal/service/recruitment/recruitment.go b/internal/service/recruitment/recruitment.go
index 69d4885..a9a7703 100644
--- a/internal/service/recruitment/recruitment.go
+++ b/internal/service/recruitment/recruitment.go
@@ -8,7 +8,6 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
- "github.com/gogf/gf/v2/os/gtime"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
@@ -197,20 +196,15 @@ func (s *recruitment) Trend(ctx context.Context, region string, category, days i
}
// Sources 数据源列表与最近运行状态。
+//
+// 性能说明:最近一次运行结果直接读 crawl_source 的 last_log_* 冗余列
+// (由 recordCrawlLog 写入),不再全表扫描 crawl_log——后者只增不减,
+// 原实现会随运行时间线性劣化。
func (s *recruitment) Sources(ctx context.Context) ([]dto.SourceItem, error) {
var srcs []entity.CrawlSource
if err := dao.CrawlSource.Ctx(ctx).OrderAsc("id").Scan(&srcs); err != nil {
return nil, gerror.Wrap(err, "查询抓取数据源失败")
}
- // 取每个源最近一条日志作为摘要。
- var logs []entity.CrawlLog
- _ = dao.CrawlLog.Ctx(ctx).OrderDesc("id").Scan(&logs)
- lastBySource := make(map[uint64]entity.CrawlLog)
- for _, l := range logs {
- if _, ok := lastBySource[l.SourceId]; !ok {
- lastBySource[l.SourceId] = l
- }
- }
out := make([]dto.SourceItem, 0, len(srcs))
for _, src := range srcs {
item := dto.SourceItem{
@@ -218,15 +212,14 @@ func (s *recruitment) Sources(ctx context.Context) ([]dto.SourceItem, error) {
Category: src.Category, Region: src.Region, Enabled: src.Enabled,
LastSuccessAt: src.LastSuccessAt, FailCount: src.FailCount,
}
- if lg, ok := lastBySource[src.Id]; ok {
- sum := "抓取"
- if lg.Error != "" {
- sum += "失败: " + lg.Error
+ // 依据冗余列拼装最近一次运行摘要(无需 JOIN crawl_log)。
+ if src.LastLogAt != nil {
+ if src.LastLogError != "" {
+ item.LastSummary = "抓取失败: " + src.LastLogError
} else {
- sum += "成功 " + gtime.New(lg.RunAt).Format("Y-m-d H:i") +
- " 新增" + strconv.Itoa(lg.NewCount) + "/抓取" + strconv.Itoa(lg.Fetched)
+ item.LastSummary = "抓取成功 " + src.LastLogAt.Format("Y-m-d H:i") +
+ " 新增" + strconv.Itoa(src.LastLogNew) + "/抓取" + strconv.Itoa(src.LastLogFetched)
}
- item.LastSummary = sum
}
out = append(out, item)
}
diff --git a/internal/service/recruitment/source_config.go b/internal/service/recruitment/source_config.go
new file mode 100644
index 0000000..ee3a104
--- /dev/null
+++ b/internal/service/recruitment/source_config.go
@@ -0,0 +1,118 @@
+// Package recruitment 的抓取适配配置。
+//
+// 本文件实现 crawl_source.config 字段(VARCHAR(1000) JSON)的解析与默认值兜底,
+// 让「抓取行为」可按数据源定制,无需改代码。设计目标:
+// - 所有字段可选,未配置则用默认值,保证历史数据零迁移即可继续工作;
+// - JSON 解析失败不报错,回退全默认值,避免一个源的脏配置拖垮整个调度。
+package recruitment
+
+import (
+ "encoding/json"
+
+ "github.com/gogf/gf/v2/frame/g"
+)
+
+// SourceConfig 是单个数据源的抓取适配配置(对应 crawl_source.config)。
+// 所有字段均可省略,零值即「用默认」。
+type SourceConfig struct {
+ // IncrDays 增量窗口天数:仅抓取该天数内发布的公告。默认 30。
+ // force=true 时忽略此项。
+ IncrDays int `json:"incrDays"`
+ // MaxItems 单次抓取详情页上限,默认 60;force 模式下放宽到 300。
+ MaxItems int `json:"maxItems"`
+ // MaxPages 列表分页最大翻页数,默认 1(不翻页)。预留字段。
+ MaxPages int `json:"maxPages"`
+ // DelayMs 详情页之间的抓取间隔(毫秒),默认 0(不限速)。
+ // 政府站对高频访问敏感,建议按源配置 300~1000。
+ DelayMs int `json:"delayMs"`
+
+ // IncludeWords 自定义标准词(命中则加分)。为空则用内置默认词表。
+ IncludeWords []string `json:"includeWords"`
+ // ExcludeWords 自定义排除词(命中则大幅减分)。为空则用内置默认词表。
+ ExcludeWords []string `json:"excludeWords"`
+ // MinScore 链接入选阈值,默认 2。调高更严格,调低更宽松。
+ MinScore int `json:"minScore"`
+
+ // DatePatterns 发布日期自定义正则(需含一个捕获组或在匹配结果内含日期)。
+ // 为空则走内置的分段抽取策略。
+ DatePatterns []string `json:"datePatterns"`
+}
+
+// 默认值常量(未配置或配置缺失时使用)。
+const (
+ defaultIncrDays = 30 // 增量窗口默认 30 天
+ defaultMaxItems = 60 // 常规抓取详情上限
+ forceMaxItems = 300 // force 全量回溯上限
+ defaultMinScore = 2 // 链接入选评分阈值
+)
+
+// parseSourceConfig 解析数据源的 config 字段。
+// 任何解析错误都静默回退为默认值,保证抓取流程不因配置问题中断。
+func parseSourceConfig(raw string) SourceConfig {
+ var cfg SourceConfig
+ raw = trimSpace(raw)
+ if raw == "" || raw == "{}" {
+ return cfg
+ }
+ if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
+ // 配置格式错误不应阻断抓取,记一条日志后按默认行为继续。
+ g.Log().Warningf(nil, "解析数据源 config 失败,按默认配置抓取: %v", err)
+ return SourceConfig{}
+ }
+ return cfg
+}
+
+// incrDays 返回增量窗口天数(含默认值兜底)。
+func (c SourceConfig) incrDays() int {
+ if c.IncrDays > 0 {
+ return c.IncrDays
+ }
+ return defaultIncrDays
+}
+
+// maxItems 返回单次抓取详情页上限。
+func (c SourceConfig) maxItems(force bool) int {
+ if force {
+ return forceMaxItems
+ }
+ if c.MaxItems > 0 {
+ return c.MaxItems
+ }
+ return defaultMaxItems
+}
+
+// minScore 返回链接入选阈值(含默认值兜底)。
+func (c SourceConfig) minScore() int {
+ if c.MinScore > 0 {
+ return c.MinScore
+ }
+ return defaultMinScore
+}
+
+// includeWordsOrDefault 返回标准词表:配置优先,否则用内置默认。
+func (c SourceConfig) includeWordsOrDefault() []string {
+ if len(c.IncludeWords) > 0 {
+ return c.IncludeWords
+ }
+ return defaultIncludeWords
+}
+
+// excludeWordsOrDefault 返回排除词表:配置优先,否则用内置默认。
+func (c SourceConfig) excludeWordsOrDefault() []string {
+ if len(c.ExcludeWords) > 0 {
+ return c.ExcludeWords
+ }
+ return defaultExcludeWords
+}
+
+// trimSpace 轻量去空白(避免为此引入 strings 依赖以外的耦合)。
+func trimSpace(s string) string {
+ start, end := 0, len(s)
+ for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
+ start++
+ }
+ for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
+ end--
+ }
+ return s[start:end]
+}
diff --git a/internal/service/recruitment/testdata/rszk.html b/internal/service/recruitment/testdata/rszk.html
new file mode 100644
index 0000000..42f1417
--- /dev/null
+++ b/internal/service/recruitment/testdata/rszk.html
@@ -0,0 +1,1034 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 贵阳市人力资源和社会保障局-
+ 人事招考
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
.png)
+
+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+