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 := ` + + ysqgk3.png + ` + 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 @@ + + + + + + + + + + + + + + + + + + + + + 贵阳市人力资源和社会保障局- + 人事招考 + + + + + + + + + + + + + + +
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + +
+
+ + + + + +
+ + + + +
+ + + + +
+
+ +
+
+
+ + + + +
+ +
+
+

法定主动
公开内容

+ +

政府信息
公开年报

+ + +

+ +

ysqgk3.png

+ + +

+ + + +
+ + + +
+
+ + + + 关闭 +
+ +
+ + + + + + + + + + +
+
+
+ +
+ + +
+ +
+
+ +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + +