feat: 招聘爬虫健壮性 + 通知记录增强(归档工作区留存改动)
这两批改动此前一直压在工作区未提交,本次一并归档。go build ./... 通过。
一、招聘爬虫健壮性
- 新增 internal/service/recruitment/source_config.go:
crawl_source.config(VARCHAR(1000) JSON)的源级抓取参数解析 —— 增量窗口天数、
单次详情页上限、最大翻页数、详情间隔限速、自定义标准词/排除词。
全部字段可选,JSON 解析失败即回退默认值,保证历史数据零迁移可用,
且一个源的脏配置不会拖垮整个调度。
- 新增 internal/service/recruitment/keywords.go(含 keywords_test.go 与 testdata/):
链接筛选由「URL 形态命中 AND 文本语义命中」改为评分制 —— URL 形态加分、
标准词加分、排除词大幅减分,达阈值即入选。原与逻辑会系统性漏抓
「补充工作人员的通知」「公开选调公务员简章」等标题变体,且静默无报错。
- 新增 manifest/sql/recruitment/004_crawl_source_status.sql:
crawl_source 增加「最近一次运行状态」冗余列,抓取结束时写入,
使 Sources() 查询零 JOIN 零扫描 —— 规避原实现全表扫描只增不减的 crawl_log、
随运行时间线性劣化的问题。幂等,可重复执行。
- internal/service/recruitment/crawler.go:在既有「静态两级 / SPA / curl 回退」
流程上做锚点抽取与过滤、编码回退的健壮性增强。
- internal/service/recruitment/bark.go、recruitment.go:配合上述调整。
二、通知记录增强
- api/notice、internal/controller/notice、internal/service/notice、
internal/model/{dto,entity,do}/notice.go:通知日志查询与操作扩展 ——
批次号、状态、重试次数、来源、耗时、详情、删除、清空。
- 新增 internal/model/dto/notice_meta.go(字典选项元数据,供前端筛选器取选项)。
- 新增 manifest/sql/017_notice_log_enhance.sql:notice_log 扩展列 +
删除/清空/详情/字典选项接口权限,幂等(ALTER 走 information_schema 判断,
菜单 ON DUPLICATE KEY UPDATE,角色绑定 INSERT IGNORE)。
三、仓库卫生
- .gitignore 补 rc_*.log 与 rc_verify*,挡掉本地离线验证产物
(含 30MB 的 rc_verify.exe),避免误入库。
设计依据见 docs/recruitment-crawler-design.md。
注:017 与 recruitment/004 两个 SQL 为增量迁移,DB 结构变更不自动 DDL,
生产库需手动导入。
This commit is contained in:
parent
4b3c00270f
commit
ad4567fc01
4
.gitignore
vendored
4
.gitignore
vendored
@ -36,3 +36,7 @@ data/
|
|||||||
.run/
|
.run/
|
||||||
service_test.exe
|
service_test.exe
|
||||||
service_test2*
|
service_test2*
|
||||||
|
|
||||||
|
# 招聘爬虫本地离线验证产物(rc_*:验证脚本编译出的二进制 + 运行日志),不入库
|
||||||
|
rc_*.log
|
||||||
|
rc_verify*
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
# service.xpcool.com 变更记录
|
# service.xpcool.com 变更记录
|
||||||
> 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要
|
> 倒序:最新在上。格式: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-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 项全过
|
2026-08-27 | CHG | 登录接口从全量加密豁免,永远只加密密码字段:APICrypto plainRoutes 增 /system/auth/login(登录请求不整体加密,handler 直接收 {username, encryptedKey, encryptedData},登录响应因无会话密钥保持明文);ResolveLogin 分支顺序调整——密码字段密文优先解密,其次 fullBody 明文、开发 allowPlain。全量模式验证 5 项(登录明文响应/info 密文/解密成功/未加密拒绝/登出)+ dev 回归 15 项全过
|
||||||
|
|||||||
@ -80,35 +80,117 @@ type NoticeRuleDeleteReq struct {
|
|||||||
|
|
||||||
type NoticeRuleDeleteRes struct{}
|
type NoticeRuleDeleteRes struct{}
|
||||||
|
|
||||||
// ---------- 通知日志 ----------
|
// ---------- 通知历史记录 ----------
|
||||||
|
// NoticeLogItem 通知历史记录行:落库字段 + 渠道/事件/类型/分组/接收人等派生展示字段。
|
||||||
type NoticeLogItem struct {
|
type NoticeLogItem struct {
|
||||||
Id uint64 `json:"id"`
|
Id uint64 `json:"id"`
|
||||||
RuleId uint64 `json:"ruleId"`
|
BatchId string `json:"batchId"` // 批次号(同一次业务触发)
|
||||||
EventType string `json:"eventType"`
|
RuleId uint64 `json:"ruleId"` // 规则ID
|
||||||
ChannelCode string `json:"channelCode"`
|
RuleName string `json:"ruleName"` // 规则名称
|
||||||
UserId uint64 `json:"userId"`
|
EventType string `json:"eventType"` // 事件编码
|
||||||
Target string `json:"target"`
|
EventName string `json:"eventName"` // 事件名称
|
||||||
Title string `json:"title"`
|
NoticeType string `json:"noticeType"` // 类型编码
|
||||||
Body string `json:"body"`
|
TypeName string `json:"typeName"` // 类型名称
|
||||||
Result int `json:"result"`
|
Group string `json:"group"` // 分组编码
|
||||||
Error string `json:"error"`
|
GroupName string `json:"groupName"` // 分组名称
|
||||||
CreatedAt string `json:"createdAt"`
|
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 {
|
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"`
|
Page int `json:"page" d:"1" v:"min:1"`
|
||||||
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
Size int `json:"size" d:"10" v:"min:1|max:100"`
|
||||||
EventType string `json:"eventType"`
|
Keyword string `json:"keyword"` // 标题/内容/目标/错误 模糊
|
||||||
ChannelCode string `json:"channelCode"`
|
NoticeType string `json:"noticeType"` // 类型编码
|
||||||
Result int `json:"result"` // 0全部 1成功 2失败
|
Group string `json:"group"` // 分组编码
|
||||||
DateFrom string `json:"dateFrom"`
|
EventType string `json:"eventType"` // 事件编码
|
||||||
DateTo string `json:"dateTo"`
|
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 {
|
type NoticeLogListRes struct {
|
||||||
List []*NoticeLogItem `json:"list"`
|
List []*NoticeLogItem `json:"list"`
|
||||||
Total int `json:"total"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 测试发送 ----------
|
// ---------- 测试发送 ----------
|
||||||
|
|||||||
272
docs/recruitment-crawler-design.md
Normal file
272
docs/recruitment-crawler-design.md
Normal file
@ -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. 优先 `<meta name="PubDate" / "publishdate" / "og:published_time">`
|
||||||
|
2. 其次带语义容器的正则:`<div class="(time|date|pubdate|info)">...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 为**手写**,勿被覆盖。
|
||||||
@ -77,25 +77,68 @@ func (c *Controller) RuleDelete(ctx context.Context, req *noticev1.NoticeRuleDel
|
|||||||
return ¬icev1.NoticeRuleDeleteRes{}, nil
|
return ¬icev1.NoticeRuleDeleteRes{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogList 通知发送记录分页。
|
// LogList 通知历史记录分页(含统计概览)。
|
||||||
func (c *Controller) LogList(ctx context.Context, req *noticev1.NoticeLogListReq) (res *noticev1.NoticeLogListRes, err error) {
|
func (c *Controller) LogList(ctx context.Context, req *noticev1.NoticeLogListReq) (res *noticev1.NoticeLogListRes, err error) {
|
||||||
list, total, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
|
list, total, stats, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
|
||||||
Page: req.Page, Size: req.Size, EventType: req.EventType,
|
Page: req.Page, Size: req.Size, Keyword: req.Keyword,
|
||||||
ChannelCode: req.ChannelCode, Result: req.Result, DateFrom: req.DateFrom, DateTo: req.DateTo,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make([]*noticev1.NoticeLogItem, 0, len(list))
|
out := make([]*noticev1.NoticeLogItem, 0, len(list))
|
||||||
for i := range list {
|
for i := range list {
|
||||||
v := &list[i]
|
out = append(out, toLogItem(&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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
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 测试通知发送。
|
// Test 测试通知发送。
|
||||||
@ -106,3 +149,29 @@ func (c *Controller) Test(ctx context.Context, req *noticev1.NoticeTestReq) (res
|
|||||||
}
|
}
|
||||||
return ¬icev1.NoticeTestRes{Result: ok, Message: msg}, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -35,15 +35,21 @@ type NoticeRule struct {
|
|||||||
type NoticeLog struct {
|
type NoticeLog struct {
|
||||||
g.Meta `orm:"table:notice_log, do:true"`
|
g.Meta `orm:"table:notice_log, do:true"`
|
||||||
Id any //
|
Id any //
|
||||||
|
BatchId any // 批次号
|
||||||
RuleId any // 规则ID
|
RuleId any // 规则ID
|
||||||
EventType any // 事件类型
|
EventType any // 事件编码
|
||||||
ChannelCode any // 渠道编码
|
ChannelCode any // 渠道编码
|
||||||
UserId any // 用户ID
|
UserId any // 用户ID
|
||||||
Target any // 发送目标
|
Target any // 发送目标
|
||||||
Title any // 标题
|
Title any // 标题
|
||||||
Body any // 内容
|
Body any // 内容
|
||||||
Result any // 结果
|
Status any // 0待发送 1成功 2失败
|
||||||
|
Result any // 1成功 0失败(兼容旧字段)
|
||||||
Error any // 错误
|
Error any // 错误
|
||||||
|
RetryCount any // 已重试次数
|
||||||
|
DurationMs any // 耗时(毫秒)
|
||||||
|
Source any // 触发来源
|
||||||
|
Remark any // 备注
|
||||||
CreatedAt any //
|
CreatedAt any //
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,21 +43,25 @@ type Organization struct {
|
|||||||
|
|
||||||
// CrawlSource 数据源 DO。
|
// CrawlSource 数据源 DO。
|
||||||
type CrawlSource struct {
|
type CrawlSource struct {
|
||||||
g.Meta `orm:"table:crawl_source, do:true"`
|
g.Meta `orm:"table:crawl_source, do:true"`
|
||||||
Id any //
|
Id any //
|
||||||
Name any // 数据源名称
|
Name any // 数据源名称
|
||||||
BaseUrl any // 站点基础URL
|
BaseUrl any // 站点基础URL
|
||||||
SourceType any // 1静态 2SPA 3需登录 4附件
|
SourceType any // 1静态 2SPA 3需登录 4附件
|
||||||
Category any // 默认分类
|
Category any // 默认分类
|
||||||
Region any // 默认地区
|
Region any // 默认地区
|
||||||
ListPath any // 列表路径/接口
|
ListPath any // 列表路径/接口
|
||||||
Config any // 扩展配置(JSON)
|
Config any // 扩展配置(JSON)
|
||||||
Enabled any // 是否启用
|
Enabled any // 是否启用
|
||||||
CronExpr any // 调度表达式
|
CronExpr any // 调度表达式
|
||||||
LastSuccessAt any // 最近成功抓取时间
|
LastSuccessAt any // 最近成功抓取时间
|
||||||
FailCount any // 连续失败次数
|
FailCount any // 连续失败次数
|
||||||
CreatedAt any //
|
LastLogAt any // 最近一次抓取时间(冗余)
|
||||||
UpdatedAt any //
|
LastLogFetched any // 最近一次抓取条数(冗余)
|
||||||
|
LastLogNew any // 最近一次新增条数(冗余)
|
||||||
|
LastLogError any // 最近一次错误信息(冗余)
|
||||||
|
CreatedAt any //
|
||||||
|
UpdatedAt any //
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrawlLog 抓取日志 DO。
|
// CrawlLog 抓取日志 DO。
|
||||||
|
|||||||
@ -47,30 +47,76 @@ type NoticeRuleInput struct {
|
|||||||
Enabled int
|
Enabled int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoticeLogVO 通知记录出参。
|
// NoticeLogVO 通知历史记录出参。
|
||||||
|
// 除落库字段外,额外补齐「渠道名称/事件名称/类型/分组/接收人」等派生字段,供表格直接展示。
|
||||||
type NoticeLogVO struct {
|
type NoticeLogVO struct {
|
||||||
Id uint64
|
Id uint64 `json:"id"` // 主键
|
||||||
RuleId uint64
|
BatchId string `json:"batchId"` // 批次号(同一次业务触发的多条投递共用)
|
||||||
EventType string
|
RuleId uint64 `json:"ruleId"` // 规则ID
|
||||||
ChannelCode string
|
RuleName string `json:"ruleName"` // 规则名称(派生,规则已删除则为空)
|
||||||
UserId uint64
|
EventType string `json:"eventType"` // 事件编码
|
||||||
Target string
|
EventName string `json:"eventName"` // 事件名称(派生)
|
||||||
Title string
|
NoticeType string `json:"noticeType"` // 类型编码(派生)
|
||||||
Body string
|
TypeName string `json:"typeName"` // 类型名称(派生)
|
||||||
Result int
|
Group string `json:"group"` // 分组编码(派生)
|
||||||
Error string
|
GroupName string `json:"groupName"` // 分组名称(派生)
|
||||||
CreatedAt string
|
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 {
|
type NoticeLogFilter struct {
|
||||||
Page int
|
Page int // 页码
|
||||||
Size int
|
Size int // 每页条数
|
||||||
EventType string
|
Keyword string // 关键字:标题/内容/目标/错误 模糊
|
||||||
ChannelCode string
|
NoticeType string // 类型编码(job/security/...)
|
||||||
Result int // 0全部 1成功 2失败
|
Group string // 分组编码(auto_job/server/...)
|
||||||
DateFrom string
|
EventType string // 事件编码
|
||||||
DateTo 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 中对应值。
|
// RenderTemplate 简单模板渲染:将 {{key}} 替换为 vars 中对应值。
|
||||||
|
|||||||
312
internal/model/dto/notice_meta.go
Normal file
312
internal/model/dto/notice_meta.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -27,19 +27,25 @@ type NoticeRule struct {
|
|||||||
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoticeLog 通知发送记录。
|
// NoticeLog 通知发送记录(通知历史记录)。
|
||||||
type NoticeLog struct {
|
type NoticeLog struct {
|
||||||
Id uint64 `json:"id" orm:"id" description:"主键"`
|
Id uint64 `json:"id" orm:"id" description:"主键"`
|
||||||
|
BatchId string `json:"batchId" orm:"batch_id" description:"批次号(同一次业务触发)"`
|
||||||
RuleId uint64 `json:"ruleId" orm:"rule_id" description:"规则ID"`
|
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:"渠道编码"`
|
ChannelCode string `json:"channelCode" orm:"channel_code" description:"渠道编码"`
|
||||||
UserId uint64 `json:"userId" orm:"user_id" description:"接收用户ID"`
|
UserId uint64 `json:"userId" orm:"user_id" description:"接收用户ID"`
|
||||||
Target string `json:"target" orm:"target" description:"发送目标"`
|
Target string `json:"target" orm:"target" description:"发送目标"`
|
||||||
Title string `json:"title" orm:"title" description:"标题"`
|
Title string `json:"title" orm:"title" description:"标题"`
|
||||||
Body string `json:"body" orm:"body" 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:"错误信息"`
|
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 自动任务定义。
|
// AutoJob 自动任务定义。
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
// 字段命名对齐 service 现有约定:json 用驼峰,orm 用下划线。
|
// 字段命名对齐 service 现有约定:json 用驼峰,orm 用下划线。
|
||||||
package entity
|
package entity
|
||||||
|
|
||||||
|
import "github.com/gogf/gf/v2/os/gtime"
|
||||||
|
|
||||||
// RecruitmentInfo 招聘考试公告主表。
|
// RecruitmentInfo 招聘考试公告主表。
|
||||||
type RecruitmentInfo struct {
|
type RecruitmentInfo struct {
|
||||||
Id uint64 `json:"id" orm:"id" description:"主键"`
|
Id uint64 `json:"id" orm:"id" description:"主键"`
|
||||||
@ -37,20 +39,24 @@ type Organization struct {
|
|||||||
|
|
||||||
// CrawlSource 数据源配置(插件化爬虫适配器)。
|
// CrawlSource 数据源配置(插件化爬虫适配器)。
|
||||||
type CrawlSource struct {
|
type CrawlSource struct {
|
||||||
Id uint64 `json:"id" orm:"id" description:"主键"`
|
Id uint64 `json:"id" orm:"id" description:"主键"`
|
||||||
Name string `json:"name" orm:"name" description:"数据源名称"`
|
Name string `json:"name" orm:"name" description:"数据源名称"`
|
||||||
BaseUrl string `json:"baseUrl" orm:"base_url" description:"站点基础URL"`
|
BaseUrl string `json:"baseUrl" orm:"base_url" description:"站点基础URL"`
|
||||||
SourceType int `json:"sourceType" orm:"source_type" description:"1静态列表 2SPA 3需登录 4附件型"`
|
SourceType int `json:"sourceType" orm:"source_type" description:"1静态列表 2SPA 3需登录 4附件型"`
|
||||||
Category int `json:"category" orm:"category" description:"该源默认考试分类"`
|
Category int `json:"category" orm:"category" description:"该源默认考试分类"`
|
||||||
Region string `json:"region" orm:"region" description:"该源默认地区"`
|
Region string `json:"region" orm:"region" description:"该源默认地区"`
|
||||||
ListPath string `json:"listPath" orm:"list_path" description:"列表页路径/接口"`
|
ListPath string `json:"listPath" orm:"list_path" description:"列表页路径/接口"`
|
||||||
Config string `json:"config" orm:"config" description:"适配器扩展配置(JSON)"`
|
Config string `json:"config" orm:"config" description:"适配器扩展配置(JSON)"`
|
||||||
Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"`
|
Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"`
|
||||||
CronExpr string `json:"cronExpr" orm:"cron_expr" description:"调度表达式(可选)"`
|
CronExpr string `json:"cronExpr" orm:"cron_expr" description:"调度表达式(可选)"`
|
||||||
LastSuccessAt string `json:"lastSuccessAt" orm:"last_success_at" description:"最近成功抓取时间"`
|
LastSuccessAt string `json:"lastSuccessAt" orm:"last_success_at" description:"最近成功抓取时间"`
|
||||||
FailCount int `json:"failCount" orm:"fail_count" description:"连续失败次数"`
|
FailCount int `json:"failCount" orm:"fail_count" description:"连续失败次数"`
|
||||||
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
|
LastLogAt *gtime.Time `json:"lastLogAt" orm:"last_log_at" description:"最近一次抓取时间(冗余)"`
|
||||||
UpdatedAt string `json:"updatedAt" orm:"updated_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 抓取任务运行日志。
|
// CrawlLog 抓取任务运行日志。
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// Package notice 提供统一通知服务(渠道/规则/发送/日志)。
|
// Package notice 提供统一通知服务(渠道/规则/发送/历史记录)。
|
||||||
// 发送实现:Bark 走路径式 key(POST {baseUrl}/{deviceKey} JSON);pushplus 走官方 send 接口。
|
// 发送实现:Bark 走路径式 key(POST {baseUrl}/{deviceKey} JSON);pushplus 走官方 send 接口。
|
||||||
package notice
|
package notice
|
||||||
|
|
||||||
@ -8,17 +8,23 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gogf/gf/v2/database/gdb"
|
||||||
"github.com/gogf/gf/v2/errors/gerror"
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
"github.com/gogf/gf/v2/os/gtime"
|
"github.com/gogf/gf/v2/os/gtime"
|
||||||
|
"github.com/gogf/gf/v2/util/guid"
|
||||||
|
|
||||||
"service.xpcool.com/internal/consts"
|
"service.xpcool.com/internal/consts"
|
||||||
"service.xpcool.com/internal/dao"
|
"service.xpcool.com/internal/dao"
|
||||||
"service.xpcool.com/internal/library/response"
|
"service.xpcool.com/internal/library/response"
|
||||||
|
"service.xpcool.com/internal/model/do"
|
||||||
"service.xpcool.com/internal/model/dto"
|
"service.xpcool.com/internal/model/dto"
|
||||||
"service.xpcool.com/internal/model/entity"
|
"service.xpcool.com/internal/model/entity"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gdbModel 查询模型别名,避免在多处书写完整包路径。
|
||||||
|
type gdbModel = *gdb.Model
|
||||||
|
|
||||||
// INotice 统一通知服务接口。
|
// INotice 统一通知服务接口。
|
||||||
type INotice interface {
|
type INotice interface {
|
||||||
ChannelList(context.Context) ([]dto.NoticeChannelVO, error)
|
ChannelList(context.Context) ([]dto.NoticeChannelVO, error)
|
||||||
@ -26,7 +32,11 @@ type INotice interface {
|
|||||||
RuleList(context.Context, string) ([]dto.NoticeRuleVO, error)
|
RuleList(context.Context, string) ([]dto.NoticeRuleVO, error)
|
||||||
RuleSave(context.Context, dto.NoticeRuleInput) (uint64, error)
|
RuleSave(context.Context, dto.NoticeRuleInput) (uint64, error)
|
||||||
RuleDelete(context.Context, 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 // 按启用规则发送通知(事件→渠道→人员)
|
Send(context.Context, string, map[string]string) error // 按启用规则发送通知(事件→渠道→人员)
|
||||||
Test(context.Context, uint64, string, string, []uint64, string, string) (bool, 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) {
|
func (s *notice) ChannelSave(ctx context.Context, in dto.NoticeChannelInput) (uint64, error) {
|
||||||
data := map[string]interface{}{
|
data := do.NoticeChannel{
|
||||||
"code": in.Code, "name": in.Name, "enabled": in.Enabled,
|
Code: in.Code, Name: in.Name, Enabled: in.Enabled,
|
||||||
"config": in.Config, "remark": in.Remark,
|
Config: in.Config, Remark: in.Remark,
|
||||||
}
|
}
|
||||||
if in.Id > 0 {
|
if in.Id > 0 {
|
||||||
if _, err := dao.NoticeChannel.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
|
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) {
|
func (s *notice) RuleSave(ctx context.Context, in dto.NoticeRuleInput) (uint64, error) {
|
||||||
data := map[string]interface{}{
|
data := do.NoticeRule{
|
||||||
"name": in.Name, "event_type": in.EventType,
|
Name: in.Name, EventType: in.EventType,
|
||||||
"channel_codes": toJSON(in.ChannelCodes), "user_ids": toJSON(in.UserIds),
|
ChannelCodes: toJSON(in.ChannelCodes), UserIds: toJSON(in.UserIds),
|
||||||
"title_template": in.TitleTemplate, "body_template": in.BodyTemplate, "enabled": in.Enabled,
|
TitleTemplate: in.TitleTemplate, BodyTemplate: in.BodyTemplate, Enabled: in.Enabled,
|
||||||
}
|
}
|
||||||
if in.Id > 0 {
|
if in.Id > 0 {
|
||||||
if _, err := dao.NoticeRule.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
|
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
|
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)
|
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 != "" {
|
if f.EventType != "" {
|
||||||
m = m.Where("event_type", 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 != "" {
|
if f.ChannelCode != "" {
|
||||||
m = m.Where("channel_code", f.ChannelCode)
|
m = m.Where("channel_code", f.ChannelCode)
|
||||||
}
|
}
|
||||||
if f.Result > 0 {
|
if f.UserId > 0 {
|
||||||
m = m.Where("result", f.Result)
|
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 != "" {
|
if f.DateFrom != "" {
|
||||||
m = m.WhereGTE("created_at", 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 != "" {
|
if f.DateTo != "" {
|
||||||
m = m.WhereLTE("created_at", f.DateTo)
|
m = m.WhereLTE("created_at", f.DateTo)
|
||||||
}
|
}
|
||||||
total, err := m.Clone().Count()
|
return m
|
||||||
if err != nil {
|
}
|
||||||
return nil, 0, gerror.Wrap(err, "统计通知日志总数失败")
|
|
||||||
|
// 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
|
stats.Failed = total - stats.Success
|
||||||
if err = m.Page(f.Page, f.Size).OrderDesc("id").Scan(&list); err != nil {
|
if stats.Failed < 0 {
|
||||||
return nil, 0, gerror.Wrap(err, "查询通知日志列表失败")
|
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))
|
out := make([]dto.NoticeLogVO, 0, len(list))
|
||||||
for _, v := range list {
|
if len(list) == 0 {
|
||||||
out = append(out, dto.NoticeLogVO{
|
return out
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
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 按事件类型查启用规则,向每个渠道的每个接收用户发送,并记录日志。
|
||||||
|
// 同一次 Send 产生的多条投递共用同一批次号(batchId),便于按批次追溯。
|
||||||
func (s *notice) Send(ctx context.Context, eventType string, vars map[string]string) error {
|
func (s *notice) Send(ctx context.Context, eventType string, vars map[string]string) error {
|
||||||
var rules []entity.NoticeRule
|
var rules []entity.NoticeRule
|
||||||
if err := dao.NoticeRule.Ctx(ctx).Where("event_type", eventType).Where("enabled", 1).Scan(&rules); err != nil {
|
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 {
|
if len(rules) == 0 {
|
||||||
return nil // 未配置规则 = 静默
|
return nil // 未配置规则 = 静默
|
||||||
}
|
}
|
||||||
|
batchId := newBatchId()
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
channels := parseStrArray(r.ChannelCodes)
|
channels := parseStrArray(r.ChannelCodes)
|
||||||
userIds := parseUintArray(r.UserIds)
|
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)
|
body := dto.RenderTemplate(r.BodyTemplate, vars)
|
||||||
for _, channel := range channels {
|
for _, channel := range channels {
|
||||||
for _, uid := range userIds {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliver 单条投递:取用户渠道凭据 → 发送 → 记日志。
|
// deliverInput 单条投递入参。
|
||||||
func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel string, userId uint64, title, body string) {
|
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
|
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
|
var target string
|
||||||
switch channel {
|
switch in.Channel {
|
||||||
case "bark":
|
case "bark":
|
||||||
target = user.BarkDeviceId
|
target = user.BarkDeviceId
|
||||||
case "pushplus":
|
case "pushplus":
|
||||||
@ -205,15 +449,19 @@ func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel
|
|||||||
default:
|
default:
|
||||||
target = ""
|
target = ""
|
||||||
}
|
}
|
||||||
ok, errMsg := s.sendByChannel(ctx, channel, target, title, body)
|
start := time.Now()
|
||||||
result := 0
|
ok, errMsg := s.sendByChannel(ctx, in.Channel, target, in.Title, in.Body)
|
||||||
|
duration := int(time.Since(start).Milliseconds())
|
||||||
|
status := dto.NoticeStatusFailed
|
||||||
if ok {
|
if ok {
|
||||||
result = 1
|
status = dto.NoticeStatusSuccess
|
||||||
}
|
}
|
||||||
_, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
|
_, _ = dao.NoticeLog.Ctx(ctx).Data(do.NoticeLog{
|
||||||
"rule_id": ruleId, "event_type": eventType, "channel_code": channel,
|
BatchId: in.BatchId, RuleId: in.RuleId, EventType: in.EventType,
|
||||||
"user_id": userId, "target": target, "title": title, "body": body,
|
ChannelCode: in.Channel, UserId: in.UserId, Target: target,
|
||||||
"result": result, "error": errMsg,
|
Title: in.Title, Body: in.Body,
|
||||||
|
Status: status, Result: boolToInt(ok), Error: errMsg,
|
||||||
|
DurationMs: duration, Source: in.Source, Remark: in.Remark,
|
||||||
}).Insert()
|
}).Insert()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,9 +497,10 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
|
|||||||
var out struct {
|
var out struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(resp.ReadAll(), &out)
|
respBody := resp.ReadAllString()
|
||||||
|
_ = json.Unmarshal([]byte(respBody), &out)
|
||||||
if out.Code != 200 {
|
if out.Code != 200 {
|
||||||
return false, "bark code " + resp.ReadAllString()
|
return false, "bark code " + respBody
|
||||||
}
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
case "pushplus":
|
case "pushplus":
|
||||||
@ -268,9 +517,10 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
|
|||||||
var out struct {
|
var out struct {
|
||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(resp.ReadAll(), &out)
|
respBody := resp.ReadAllString()
|
||||||
|
_ = json.Unmarshal([]byte(respBody), &out)
|
||||||
if out.Code != 200 {
|
if out.Code != 200 {
|
||||||
return false, "pushplus code " + resp.ReadAllString()
|
return false, "pushplus code " + respBody
|
||||||
}
|
}
|
||||||
return true, ""
|
return true, ""
|
||||||
}
|
}
|
||||||
@ -279,6 +529,7 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
|
|||||||
|
|
||||||
// Test 测试发送:优先按规则;否则手动指定渠道+目标。
|
// Test 测试发送:优先按规则;否则手动指定渠道+目标。
|
||||||
func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target string, userIds []uint64, title, body string) (bool, string, error) {
|
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 {
|
if ruleId > 0 {
|
||||||
var r entity.NoticeRule
|
var r entity.NoticeRule
|
||||||
if err := dao.NoticeRule.Ctx(ctx).Where("id", ruleId).Scan(&r); err != nil || r.Id == 0 {
|
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 _, ch := range parseStrArray(r.ChannelCodes) {
|
||||||
for _, uid := range parseUintArray(r.UserIds) {
|
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
|
return true, "已按规则投递", nil
|
||||||
@ -295,9 +550,11 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
|
|||||||
if channelCode == "" {
|
if channelCode == "" {
|
||||||
return false, "", response.Error(consts.CodeInvalidParam, "请指定渠道")
|
return false, "", response.Error(consts.CodeInvalidParam, "请指定渠道")
|
||||||
}
|
}
|
||||||
|
userId := uint64(0)
|
||||||
if target == "" && len(userIds) > 0 {
|
if target == "" && len(userIds) > 0 {
|
||||||
|
userId = userIds[0]
|
||||||
var user entity.AdminUser
|
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" {
|
if channelCode == "bark" {
|
||||||
target = user.BarkDeviceId
|
target = user.BarkDeviceId
|
||||||
} else if channelCode == "pushplus" {
|
} else if channelCode == "pushplus" {
|
||||||
@ -307,17 +564,30 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
|
|||||||
if target == "" {
|
if target == "" {
|
||||||
return false, "", response.Error(consts.CodeInvalidParam, "目标凭据为空")
|
return false, "", response.Error(consts.CodeInvalidParam, "目标凭据为空")
|
||||||
}
|
}
|
||||||
|
start := time.Now()
|
||||||
ok, msg := s.sendByChannel(ctx, channelCode, target, title, body)
|
ok, msg := s.sendByChannel(ctx, channelCode, target, title, body)
|
||||||
_, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
|
duration := int(time.Since(start).Milliseconds())
|
||||||
"rule_id": 0, "event_type": "test", "channel_code": channelCode,
|
status := dto.NoticeStatusFailed
|
||||||
"user_id": 0, "target": target, "title": title, "body": body,
|
if ok {
|
||||||
"result": boolToInt(ok), "error": msg,
|
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()
|
}).Insert()
|
||||||
return ok, msg, nil
|
return ok, msg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- 工具 ----------------
|
// ---------------- 工具 ----------------
|
||||||
|
|
||||||
|
// newBatchId 生成投递批次号(同一次业务触发共用一个)。
|
||||||
|
func newBatchId() string {
|
||||||
|
return guid.S()[:16]
|
||||||
|
}
|
||||||
|
|
||||||
func parseStrArray(s string) []string {
|
func parseStrArray(s string) []string {
|
||||||
var out []string
|
var out []string
|
||||||
_ = json.Unmarshal([]byte(s), &out)
|
_ = json.Unmarshal([]byte(s), &out)
|
||||||
|
|||||||
@ -89,13 +89,13 @@ func barkPush(ctx context.Context, deviceKey, title, body string) (bool, string,
|
|||||||
func sendDailyDigest(ctx context.Context) {
|
func sendDailyDigest(ctx context.Context) {
|
||||||
var subs []entity.PushSubscription
|
var subs []entity.PushSubscription
|
||||||
if err := dao.PushSubscription.Ctx(ctx).Where("enabled", 1).Scan(&subs); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if len(subs) == 0 {
|
if len(subs) == 0 {
|
||||||
def := defaultSubscription(ctx)
|
def := defaultSubscription(ctx)
|
||||||
if def == nil {
|
if def == nil {
|
||||||
g.Log().Infof(ctx, "no subscription and no default bark device key, skip digest")
|
g.Log().Infof(ctx, "无启用订阅且未配置兜底设备密钥,跳过早报推送")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
subs = []entity.PushSubscription{*def}
|
subs = []entity.PushSubscription{*def}
|
||||||
@ -125,7 +125,7 @@ func buildDigest(ctx context.Context, sub entity.PushSubscription) (string, stri
|
|||||||
}
|
}
|
||||||
var list []dto.RecruitmentInfoVO
|
var list []dto.RecruitmentInfoVO
|
||||||
if err := m.Fields("title, category, region, deadline").OrderDesc("publish_date").Scan(&list); err != nil {
|
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 "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
if sub.OnlyNew == 1 && len(list) == 0 {
|
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,
|
SubscriptionId: subId, PushAt: gtime.Now(), Title: title, Body: body,
|
||||||
Result: res, Error: msg, CreatedAt: gtime.Now(),
|
Result: res, Error: msg, CreatedAt: gtime.Now(),
|
||||||
}).Insert(); err != nil {
|
}).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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -70,7 +70,12 @@ func crawlerRun(ctx context.Context, src entity.CrawlSource, force bool) dto.Cra
|
|||||||
}
|
}
|
||||||
|
|
||||||
// genericStaticCrawl 通用静态列表抓取:取列表页锚点 → 逐条抓详情 → 抽取字段。
|
// genericStaticCrawl 通用静态列表抓取:取列表页锚点 → 逐条抓详情 → 抽取字段。
|
||||||
|
//
|
||||||
|
// 抓取行为(增量窗口、详情上限、限速、词表、阈值)全部来自 crawl_source.config,
|
||||||
|
// 未配置则走内置默认值,保证历史数据无需迁移即可继续工作。
|
||||||
func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
|
func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
|
||||||
|
cfg := parseSourceConfig(src.Config)
|
||||||
|
|
||||||
listURL := joinURL(src.BaseUrl, src.ListPath)
|
listURL := joinURL(src.BaseUrl, src.ListPath)
|
||||||
if listURL == "" {
|
if listURL == "" {
|
||||||
listURL = src.BaseUrl
|
listURL = src.BaseUrl
|
||||||
@ -79,32 +84,39 @@ func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g.Log().Warningf(ctx, "[recruit-debug] src=%d listURL=%s htmlLen=%d", src.Id, listURL, len(html))
|
|
||||||
raw := extractAnchors(html, listURL)
|
raw := extractAnchors(html, listURL)
|
||||||
anchors := filterArticleAnchors(raw)
|
anchors := filterArticleAnchors(raw, cfg)
|
||||||
g.Log().Warningf(ctx, "[recruit-debug] src=%d rawAnchors=%d filtered=%d", src.Id, len(raw), len(anchors))
|
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
|
var out []dto.RecruitmentInput
|
||||||
limit := 60
|
dateMissing := 0 // 未能识别发布日期的条数,用于在日志中告警
|
||||||
if force {
|
|
||||||
limit = 300 // 全量回溯放宽上限
|
|
||||||
}
|
|
||||||
for _, a := range anchors {
|
for _, a := range anchors {
|
||||||
if len(out) >= limit {
|
if len(out) >= limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if cfg.DelayMs > 0 {
|
||||||
|
// 限速:政府站对高频访问敏感,按源配置间隔。
|
||||||
|
time.Sleep(time.Duration(cfg.DelayMs) * time.Millisecond)
|
||||||
|
}
|
||||||
dHtml, e := fetchHTML(ctx, a.URL)
|
dHtml, e := fetchHTML(ctx, a.URL)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
title, content, pub, dl, ex := extractDetail(dHtml)
|
title, content, pub, dl, ex := extractDetail(dHtml, cfg)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = a.Text
|
title = a.Text
|
||||||
}
|
}
|
||||||
pubDate := normalizeDate(pub)
|
pubDate := normalizeDate(pub)
|
||||||
// 增量模式:跳过 30 天前的公告,避免无效回扫。
|
if pubDate == "" {
|
||||||
|
dateMissing++
|
||||||
|
}
|
||||||
|
// 增量模式:跳过窗口期之外的历史公告,避免无效回扫。force 时忽略窗口。
|
||||||
if !force && pubDate != "" {
|
if !force && pubDate != "" {
|
||||||
if t, e2 := time.Parse("2006-01-02", pubDate); e2 == nil {
|
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
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,6 +135,10 @@ func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool)
|
|||||||
Status: 0,
|
Status: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if dateMissing > 0 {
|
||||||
|
// 发布日期识别失败会影响增量判断与趋势统计,需显式暴露而非静默。
|
||||||
|
g.Log().Warningf(ctx, "源 %d 有 %d 条未识别发布日期,已置空(建议检查 datePatterns 配置)", src.Id, dateMissing)
|
||||||
|
}
|
||||||
return out, nil
|
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 {
|
if len(extractAnchors(html, listURL)) == 0 {
|
||||||
return nil, gerror.New("SPA 无服务端渲染内容,需接入底层 JSON 接口(阶段0 POC)")
|
return nil, gerror.New("SPA 无服务端渲染内容,需接入底层 JSON 接口(阶段0 POC)")
|
||||||
}
|
}
|
||||||
|
// 有服务端渲染锚点时,退回通用静态抓取(内部会读取 config 适配)。
|
||||||
return genericStaticCrawl(ctx, src, force)
|
return genericStaticCrawl(ctx, src, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,12 +210,13 @@ func walkJSON(node any, out *[]dto.RecruitmentInput) {
|
|||||||
// 而 Go 客户端可能被 TLS 指纹级 WAF 拦截返回无内容挑战页),失败再回退 Go 客户端。
|
// 而 Go 客户端可能被 TLS 指纹级 WAF 拦截返回无内容挑战页),失败再回退 Go 客户端。
|
||||||
func fetchHTML(ctx context.Context, u string) (string, error) {
|
func fetchHTML(ctx context.Context, u string) (string, error) {
|
||||||
if body, e := fetchWithCurl(u); e == nil && looksLikeHtml(body) && len(body) >= 3000 {
|
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
|
return body, nil
|
||||||
} else if e != 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 {
|
} 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 := g.Client()
|
||||||
client.SetTimeout(15 * time.Second)
|
client.SetTimeout(15 * time.Second)
|
||||||
@ -215,10 +233,10 @@ func fetchHTML(ctx context.Context, u string) (string, error) {
|
|||||||
}
|
}
|
||||||
body := resp.ReadAllString()
|
body := resp.ReadAllString()
|
||||||
if !looksLikeHtml(body) || len(body) < 3000 {
|
if !looksLikeHtml(body) || len(body) < 3000 {
|
||||||
g.Log().Warningf(ctx, "[recruit-debug] go-fallback suspicious len=%d url=%s", len(body), u)
|
// Go 客户端被 TLS 指纹级 WAF 拦截时会返回无内容的挑战页,这里显式报错便于排查。
|
||||||
return "", gerror.Newf("页面内容可疑(长度=%d)%s", len(body), u)
|
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
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -262,8 +280,24 @@ var (
|
|||||||
dateRe = regexp.MustCompile(`(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})`)
|
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*;?`)
|
inlineJSONRe = regexp.MustCompile(`(?is)(?:__NEXT_DATA__|__INITIAL_STATE__|window\.__[A-Z_]+)\s*=\s*(\{[\s\S]*?\})\s*;?`)
|
||||||
spaceRe = regexp.MustCompile(`\s+`)
|
spaceRe = regexp.MustCompile(`\s+`)
|
||||||
// recruitKw 招聘类公告常见语义词,用于从政府站链接中识别公告条目。
|
// yearPathRe 匹配 URL 路径中的年份(如 /2026/ 或 /202609/),用于判断内容页形态。
|
||||||
recruitKw = regexp.MustCompile(`招聘|招考|公招|选聘|引进|人才|公告|公示|录用|录取|面试|笔试|报名|选调|遴选|拟聘|聘用|招募|招录|考试|考录|体检|考察|资格复审|递补`)
|
yearPathRe = regexp.MustCompile(`/(20\d{2})[/_]?(\d{2})?/`)
|
||||||
|
// imgAltRe 抽取 <img> 的 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)<meta[^>]+name=["']?(?:PubDate|publishdate|publish_date|Pubdate)["']?[^>]+content=["']([^"']+)["']`),
|
||||||
|
regexp.MustCompile(`(?i)<meta[^>]+content=["']([^"']+)["'][^>]+name=["']?(?:PubDate|publishdate|publish_date)["']?`),
|
||||||
|
regexp.MustCompile(`(?i)<meta[^>]+property=["']?(?:og:published_time|article:published_time|og:release_date)["']?[^>]+content=["']([^"']+)["']`),
|
||||||
|
regexp.MustCompile(`(?i)<meta[^>]+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 + 文本)。
|
// extractAnchors 从 HTML 抽取绝对化后的锚点(URL + 文本)。
|
||||||
@ -281,15 +315,27 @@ func extractAnchors(html, base string) []anchor {
|
|||||||
strings.HasPrefix(href, "#") || strings.HasPrefix(href, "mailto:") {
|
strings.HasPrefix(href, "#") || strings.HasPrefix(href, "mailto:") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
inner := strings.TrimSpace(stripTags(block[1]))
|
// 注意:stripTags 会把 <img> 替换为空白,图片锚点(如仅含
|
||||||
|
// <img src="ysqgk3.png" title="ysqgk3.png">)经去标签后 inner 为空,
|
||||||
|
// 随后的 title 属性回退会取到图片文件名,从而绕过语义词表。
|
||||||
|
// 故先尝试用 img 的 alt 作为文本,再回退 <a> 的 title,最后统一做
|
||||||
|
// 「图片文件名」筛除,避免这类噪声进入评分。
|
||||||
|
inner := strings.TrimSpace(collapseSpace(stripTags(block[1])))
|
||||||
text := inner
|
text := inner
|
||||||
if tm := titleAttrRe.FindStringSubmatch(full); len(tm) >= 2 {
|
if text == "" || isImageFileName(text) {
|
||||||
t := strings.TrimSpace(tm[1])
|
text = firstAttr(block[1], imgAltRe)
|
||||||
if len([]rune(text)) < 4 && t != "" {
|
}
|
||||||
text = t
|
// 文本过短时回退到 <a> 的 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
|
continue
|
||||||
}
|
}
|
||||||
abs := resolveURL(base, href)
|
abs := resolveURL(base, href)
|
||||||
@ -302,23 +348,22 @@ func extractAnchors(html, base string) []anchor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// filterArticleAnchors 过滤导航/无用链接,保留疑似招聘公告条目并去重。
|
// 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))
|
out := make([]anchor, 0, len(as))
|
||||||
for _, a := range as {
|
for _, a := range as {
|
||||||
text := strings.TrimSpace(a.Text)
|
text := strings.TrimSpace(a.Text)
|
||||||
if len([]rune(text)) < 4 {
|
if len([]rune(text)) < 4 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u := strings.ToLower(a.URL)
|
if scoreAnchor(a, incWords, excWords, minScore) {
|
||||||
// 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 {
|
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -334,17 +379,26 @@ func filterArticleAnchors(as []anchor) []anchor {
|
|||||||
return uniq
|
return uniq
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractDetail 从详情页抽取标题/正文/发布日期/报名截止/笔试日期(启发式,可按源调优)。
|
// extractDetail 从详情页抽取标题/正文/发布日期/报名截止/笔试日期。
|
||||||
func extractDetail(html string) (title, content, publishDate, deadline, examDate string) {
|
//
|
||||||
|
// 发布日期采用「分段抽取」策略(修复原 firstDate 取整页首个日期、误把
|
||||||
|
// 页面头部"今天是2026年9月13日"当作发布日的问题):
|
||||||
|
// 1. 优先 <meta> 的 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 {
|
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 {
|
if m := h1Re.FindStringSubmatch(html); len(m) > 1 {
|
||||||
title = stripTags(m[1])
|
title = cleanText(m[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
|
|
||||||
body := scriptRe.ReplaceAllString(html, " ")
|
body := scriptRe.ReplaceAllString(html, " ")
|
||||||
body = styleRe.ReplaceAllString(body, " ")
|
body = styleRe.ReplaceAllString(body, " ")
|
||||||
text := stripTags(body)
|
text := stripTags(body)
|
||||||
@ -353,19 +407,76 @@ func extractDetail(html string) (title, content, publishDate, deadline, examDate
|
|||||||
text = string([]rune(text)[:4000])
|
text = string([]rune(text)[:4000])
|
||||||
}
|
}
|
||||||
content = text
|
content = text
|
||||||
publishDate = normalizeDate(firstDate(html))
|
|
||||||
deadline = normalizeDate(findDateAfter(text, "报名"))
|
publishDate = extractPublishDate(html, text, cfg)
|
||||||
examDate = normalizeDate(findDateAfter(text, "笔试"))
|
// 截止/笔试日期在正文中按关键词就近查找:
|
||||||
|
// 政府公告常写「报名时间:2026年9月1日9:00至9月5日17:00」,
|
||||||
|
// 故限制关键词后 40 字内取证,避免取到正文别处的无关日期。
|
||||||
|
deadline = normalizeDate(findDateAfterLimit(text, "报名", 40))
|
||||||
|
examDate = normalizeDate(findDateAfterLimit(text, "笔试", 40))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// firstDate 返回文本中首个日期(归一化后)。
|
// extractPublishDate 按优先级分段抽取发布日期,全部未命中返回空串。
|
||||||
func firstDate(s string) string {
|
func extractPublishDate(html, text string, cfg SourceConfig) string {
|
||||||
m := dateRe.FindStringSubmatch(s)
|
// 1. meta 标准字段(最可靠)。
|
||||||
|
for _, re := range metaDateRes {
|
||||||
|
if m := re.FindStringSubmatch(html); len(m) > 1 {
|
||||||
|
if d := normalizeDate(m[1]); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 语义容器内的日期(如 <div class="time">2026-09-13</div>)。
|
||||||
|
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 {
|
if len(m) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return normalizeDate(m[0])
|
return m[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// findDateAfter 在 keyword 之后查找下一个日期(用于报名/笔试时间)。
|
// findDateAfter 在 keyword 之后查找下一个日期(用于报名/笔试时间)。
|
||||||
@ -406,6 +517,30 @@ func collapseSpace(s string) string {
|
|||||||
return spaceRe.ReplaceAllString(s, " ")
|
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。
|
// resolveURL 将相对链接解析为绝对 URL。
|
||||||
func resolveURL(base, href string) string {
|
func resolveURL(base, href string) string {
|
||||||
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
|
||||||
@ -563,11 +698,20 @@ func nullIfEmpty(s string) any {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordCrawlLog 写抓取日志并更新数据源运行状态(成功清失败计数;失败累加)。
|
// autoDisableThreshold 连续失败达该次数后自动禁用数据源,避免无效空跑。
|
||||||
|
// 采用「连续」语义:中间任意一次成功即归零,避免偶发网络抖动误禁。
|
||||||
|
const autoDisableThreshold = 5
|
||||||
|
|
||||||
|
// autoDisableWarnThreshold 连续失败达该次数时先推送一次预警(尚未禁用)。
|
||||||
|
const autoDisableWarnThreshold = 3
|
||||||
|
|
||||||
|
// recordCrawlLog 写抓取日志并更新数据源运行状态。
|
||||||
|
// - 成功:清失败计数、更新 LastSuccessAt 与最近运行摘要冗余列;
|
||||||
|
// - 失败:累加失败计数,达阈值自动禁用并推送告警。
|
||||||
func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
|
func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if res.Err != nil {
|
if res.Err != nil {
|
||||||
// 错误可能含整页 SQL,截断避免超过 error 列长度导致日志也写不进。
|
// 错误可能含整页内容,截断避免超过 error 列长度导致日志也写不进。
|
||||||
errMsg = res.Err.Error()
|
errMsg = res.Err.Error()
|
||||||
const maxErr = 900
|
const maxErr = 900
|
||||||
if len(errMsg) > maxErr {
|
if len(errMsg) > maxErr {
|
||||||
@ -580,15 +724,54 @@ func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
|
|||||||
}).Insert(); err != nil {
|
}).Insert(); err != nil {
|
||||||
g.Log().Errorf(ctx, "写入抓取日志失败: %v", err)
|
g.Log().Errorf(ctx, "写入抓取日志失败: %v", err)
|
||||||
}
|
}
|
||||||
if res.Err != nil {
|
|
||||||
var src entity.CrawlSource
|
// 读取当前源状态,用于累计失败次数与判断是否需自动禁用。
|
||||||
_ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Scan(&src)
|
var src entity.CrawlSource
|
||||||
_, _ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).
|
if err := dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Scan(&src); err != nil {
|
||||||
Data(do.CrawlSource{FailCount: src.FailCount + 1}).Update()
|
if !strings.Contains(err.Error(), "no rows") {
|
||||||
|
g.Log().Errorf(ctx, "查询数据源状态失败: %v", err)
|
||||||
|
}
|
||||||
return
|
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] + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 订阅辅助 ----------
|
// ---------- 订阅辅助 ----------
|
||||||
|
|||||||
170
internal/service/recruitment/keywords.go
Normal file
170
internal/service/recruitment/keywords.go
Normal file
@ -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
|
||||||
|
}
|
||||||
205
internal/service/recruitment/keywords_test.go
Normal file
205
internal/service/recruitment/keywords_test.go
Normal file
@ -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人事招考", "人事招考"},
|
||||||
|
{"贵阳贵安 <b>2026</b> 年招聘公告", "贵阳贵安 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 验证图片型锚点不会以「文件名」形式进入候选。
|
||||||
|
// 复现真实页面:<a href=".../index.html"><img src="ysqgk3.png" title="ysqgk3.png"></a>
|
||||||
|
func TestExtractAnchorsImageFallback(t *testing.T) {
|
||||||
|
html := `<html><body>
|
||||||
|
<a href="http://rsj.guiyang.gov.cn/zfxxgk/ysqgk/index.html" id='gknb'>
|
||||||
|
<img src="../../images/ysqgk3.png" alt="ysqgk3.png" title="ysqgk3.png" /></a>
|
||||||
|
</body></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)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
internal/service/recruitment/live_diag_test.go
Normal file
39
internal/service/recruitment/live_diag_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gogf/gf/v2/errors/gerror"
|
"github.com/gogf/gf/v2/errors/gerror"
|
||||||
"github.com/gogf/gf/v2/frame/g"
|
"github.com/gogf/gf/v2/frame/g"
|
||||||
"github.com/gogf/gf/v2/os/gtime"
|
|
||||||
|
|
||||||
"service.xpcool.com/internal/consts"
|
"service.xpcool.com/internal/consts"
|
||||||
"service.xpcool.com/internal/dao"
|
"service.xpcool.com/internal/dao"
|
||||||
@ -197,20 +196,15 @@ func (s *recruitment) Trend(ctx context.Context, region string, category, days i
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sources 数据源列表与最近运行状态。
|
// Sources 数据源列表与最近运行状态。
|
||||||
|
//
|
||||||
|
// 性能说明:最近一次运行结果直接读 crawl_source 的 last_log_* 冗余列
|
||||||
|
// (由 recordCrawlLog 写入),不再全表扫描 crawl_log——后者只增不减,
|
||||||
|
// 原实现会随运行时间线性劣化。
|
||||||
func (s *recruitment) Sources(ctx context.Context) ([]dto.SourceItem, error) {
|
func (s *recruitment) Sources(ctx context.Context) ([]dto.SourceItem, error) {
|
||||||
var srcs []entity.CrawlSource
|
var srcs []entity.CrawlSource
|
||||||
if err := dao.CrawlSource.Ctx(ctx).OrderAsc("id").Scan(&srcs); err != nil {
|
if err := dao.CrawlSource.Ctx(ctx).OrderAsc("id").Scan(&srcs); err != nil {
|
||||||
return nil, gerror.Wrap(err, "查询抓取数据源失败")
|
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))
|
out := make([]dto.SourceItem, 0, len(srcs))
|
||||||
for _, src := range srcs {
|
for _, src := range srcs {
|
||||||
item := dto.SourceItem{
|
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,
|
Category: src.Category, Region: src.Region, Enabled: src.Enabled,
|
||||||
LastSuccessAt: src.LastSuccessAt, FailCount: src.FailCount,
|
LastSuccessAt: src.LastSuccessAt, FailCount: src.FailCount,
|
||||||
}
|
}
|
||||||
if lg, ok := lastBySource[src.Id]; ok {
|
// 依据冗余列拼装最近一次运行摘要(无需 JOIN crawl_log)。
|
||||||
sum := "抓取"
|
if src.LastLogAt != nil {
|
||||||
if lg.Error != "" {
|
if src.LastLogError != "" {
|
||||||
sum += "失败: " + lg.Error
|
item.LastSummary = "抓取失败: " + src.LastLogError
|
||||||
} else {
|
} else {
|
||||||
sum += "成功 " + gtime.New(lg.RunAt).Format("Y-m-d H:i") +
|
item.LastSummary = "抓取成功 " + src.LastLogAt.Format("Y-m-d H:i") +
|
||||||
" 新增" + strconv.Itoa(lg.NewCount) + "/抓取" + strconv.Itoa(lg.Fetched)
|
" 新增" + strconv.Itoa(src.LastLogNew) + "/抓取" + strconv.Itoa(src.LastLogFetched)
|
||||||
}
|
}
|
||||||
item.LastSummary = sum
|
|
||||||
}
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
|
|||||||
118
internal/service/recruitment/source_config.go
Normal file
118
internal/service/recruitment/source_config.go
Normal file
@ -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]
|
||||||
|
}
|
||||||
1034
internal/service/recruitment/testdata/rszk.html
vendored
Normal file
1034
internal/service/recruitment/testdata/rszk.html
vendored
Normal file
File diff suppressed because it is too large
Load Diff
76
manifest/sql/017_notice_log_enhance.sql
Normal file
76
manifest/sql/017_notice_log_enhance.sql
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
-- 017_notice_log_enhance.sql
|
||||||
|
-- 通知历史记录增强:notice_log 扩展字段(批次/类型统计/耗时/来源/重试/备注)
|
||||||
|
-- + 删除/清空/详情 接口权限 + 字典选项接口权限。
|
||||||
|
-- 幂等:ALTER TABLE ADD COLUMN 用 information_schema 判断(MySQL 8 不支持 COLUMN IF NOT EXISTS);
|
||||||
|
-- 菜单用 INSERT ... ON DUPLICATE KEY UPDATE;角色绑定用 INSERT IGNORE。
|
||||||
|
-- 依赖:016_user_notice_job.sql 已执行(notice_log / admin_menu / admin_role_menu 已存在)。
|
||||||
|
|
||||||
|
-- ============ 1) notice_log 扩展字段 ============
|
||||||
|
-- batch_id:同一次业务触发(如一次任务失败同时发 bark + pushplus)共用,便于按批次追溯
|
||||||
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND COLUMN_NAME='batch_id');
|
||||||
|
SET @sql := IF(@has_col=0, 'ALTER TABLE notice_log ADD COLUMN batch_id VARCHAR(32) NOT NULL DEFAULT '''' COMMENT ''批次号(同一次业务触发)'' AFTER id', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- status:0待发送 1成功 2失败(result 为旧字段 1成功 0失败,保留兼容)
|
||||||
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND COLUMN_NAME='status');
|
||||||
|
SET @sql := IF(@has_col=0, 'ALTER TABLE notice_log ADD COLUMN status TINYINT NOT NULL DEFAULT 1 COMMENT ''0待发送 1成功 2失败'' AFTER body', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- retry_count:已重试次数
|
||||||
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND COLUMN_NAME='retry_count');
|
||||||
|
SET @sql := IF(@has_col=0, 'ALTER TABLE notice_log ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT ''已重试次数'' AFTER error', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- duration_ms:渠道接口耗时
|
||||||
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND COLUMN_NAME='duration_ms');
|
||||||
|
SET @sql := IF(@has_col=0, 'ALTER TABLE notice_log ADD COLUMN duration_ms INT NOT NULL DEFAULT 0 COMMENT ''渠道接口耗时(毫秒)'' AFTER retry_count', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- source:触发来源 auto/manual/test
|
||||||
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND COLUMN_NAME='source');
|
||||||
|
SET @sql := IF(@has_col=0, 'ALTER TABLE notice_log ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT ''auto'' COMMENT ''触发来源 auto/manual/test'' AFTER duration_ms', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- remark:备注
|
||||||
|
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND COLUMN_NAME='remark');
|
||||||
|
SET @sql := IF(@has_col=0, 'ALTER TABLE notice_log ADD COLUMN remark VARCHAR(500) NOT NULL DEFAULT '''' COMMENT ''备注'' AFTER source', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 索引:渠道+时间、状态+时间、批次
|
||||||
|
SET @has_idx := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND INDEX_NAME='idx_channel');
|
||||||
|
SET @sql := IF(@has_idx=0, 'ALTER TABLE notice_log ADD INDEX idx_channel (channel_code, created_at)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @has_idx := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND INDEX_NAME='idx_status');
|
||||||
|
SET @sql := IF(@has_idx=0, 'ALTER TABLE notice_log ADD INDEX idx_status (status, created_at)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
SET @has_idx := (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='notice_log' AND INDEX_NAME='idx_batch');
|
||||||
|
SET @sql := IF(@has_idx=0, 'ALTER TABLE notice_log ADD INDEX idx_batch (batch_id)', 'SELECT 1');
|
||||||
|
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||||
|
|
||||||
|
-- 历史数据回填:result=1 → status=1;result=0 且有错误 → status=2
|
||||||
|
UPDATE notice_log SET status=1 WHERE status=0 AND result=1;
|
||||||
|
UPDATE notice_log SET status=2 WHERE status=0 AND result=0 AND error <> '';
|
||||||
|
UPDATE notice_log SET source='test' WHERE event_type='test' AND source='auto';
|
||||||
|
|
||||||
|
-- ============ 2) 菜单:通知历史记录(替换原「通知日志」并调整排序) ============
|
||||||
|
-- 982 原为「通知日志」,此处改名为「通知历史」以贴合需求(保留 id 以延续角色绑定)
|
||||||
|
UPDATE admin_menu SET name='通知历史', icon='mdi:history', component='system/notice/log/index', permission='notice:log', sort=3
|
||||||
|
WHERE id=982 AND type=1;
|
||||||
|
|
||||||
|
-- ============ 3) type=2 API 权限:通知历史记录 ============
|
||||||
|
-- 路径必须与 api/notice/notice.go 的 g.Meta path 完全一致(含 /api/service/admin 前缀)
|
||||||
|
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
|
||||||
|
(9821, 982, '查询', '', 2, 'POST /api/service/admin/notice/log/list', '', 'notice:log:list', 1, 1, 0, NOW(), NOW()),
|
||||||
|
(9822, 982, '详情', '', 2, 'POST /api/service/admin/notice/log/detail', '', 'notice:log:detail', 2, 1, 0, NOW(), NOW()),
|
||||||
|
(9823, 982, '删除', '', 2, 'POST /api/service/admin/notice/log/delete', '', 'notice:log:delete', 3, 1, 0, NOW(), NOW()),
|
||||||
|
(9824, 982, '清空', '', 2, 'POST /api/service/admin/notice/log/clear', '', 'notice:log:clear', 4, 1, 0, NOW(), NOW()),
|
||||||
|
(9825, 982, '选项', '', 2, 'POST /api/service/admin/notice/log/options','', 'notice:log:options',5, 1, 1, NOW(), NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), name=VALUES(name), type=VALUES(type),
|
||||||
|
path=VALUES(path), permission=VALUES(permission), sort=VALUES(sort), status=VALUES(status),
|
||||||
|
hidden=VALUES(hidden), deleted_at=NULL;
|
||||||
|
|
||||||
|
-- ============ 4) 超管角色绑定 ============
|
||||||
|
INSERT IGNORE INTO admin_role_menu (role_id, menu_id, created_at, updated_at)
|
||||||
|
SELECT 1, id, NOW(), NOW() FROM admin_menu WHERE id IN (982, 9821, 9822, 9823, 9824, 9825);
|
||||||
70
manifest/sql/recruitment/004_crawl_source_status.sql
Normal file
70
manifest/sql/recruitment/004_crawl_source_status.sql
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 004_crawl_source_status.sql
|
||||||
|
-- 目的:为 crawl_source 增加「最近一次运行状态」冗余列。
|
||||||
|
--
|
||||||
|
-- 背景:原 Sources() 查询需全表扫描 crawl_log(只增不减的表)以取每源最近一条,
|
||||||
|
-- 随运行时间线性劣化。改为在抓取结束时把最近结果写入 crawl_source 冗余列,
|
||||||
|
-- 查询时零 JOIN、零扫描。
|
||||||
|
--
|
||||||
|
-- 幂等设计:可重复执行,已存在的列会被跳过。
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
USE `recruitment`;
|
||||||
|
|
||||||
|
-- MySQL 8.0 不支持 ADD COLUMN IF NOT EXISTS,故用存储过程判断后再执行。
|
||||||
|
DROP PROCEDURE IF EXISTS `add_crawl_source_status_cols`;
|
||||||
|
|
||||||
|
DELIMITER $$
|
||||||
|
CREATE PROCEDURE `add_crawl_source_status_cols`()
|
||||||
|
BEGIN
|
||||||
|
-- 最近一次抓取时间
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'recruitment' AND TABLE_NAME = 'crawl_source' AND COLUMN_NAME = 'last_log_at'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `crawl_source`
|
||||||
|
ADD COLUMN `last_log_at` DATETIME NULL DEFAULT NULL COMMENT '最近一次抓取时间(冗余,避免全表扫描 crawl_log)';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 最近一次抓取条数
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'recruitment' AND TABLE_NAME = 'crawl_source' AND COLUMN_NAME = 'last_log_fetched'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `crawl_source`
|
||||||
|
ADD COLUMN `last_log_fetched` INT NOT NULL DEFAULT 0 COMMENT '最近一次抓取条数(冗余)';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 最近一次新增条数
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'recruitment' AND TABLE_NAME = 'crawl_source' AND COLUMN_NAME = 'last_log_new'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `crawl_source`
|
||||||
|
ADD COLUMN `last_log_new` INT NOT NULL DEFAULT 0 COMMENT '最近一次新增条数(冗余)';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 最近一次错误信息
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.COLUMNS
|
||||||
|
WHERE TABLE_SCHEMA = 'recruitment' AND TABLE_NAME = 'crawl_source' AND COLUMN_NAME = 'last_log_error'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE `crawl_source`
|
||||||
|
ADD COLUMN `last_log_error` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '最近一次错误信息(冗余)';
|
||||||
|
END IF;
|
||||||
|
END$$
|
||||||
|
DELIMITER ;
|
||||||
|
|
||||||
|
CALL `add_crawl_source_status_cols`();
|
||||||
|
DROP PROCEDURE IF EXISTS `add_crawl_source_status_cols`;
|
||||||
|
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
-- 数据源适配配置初始化:
|
||||||
|
-- 为两个已启用的静态源补一份 config,启用增量窗口与限速(后续可按需调整)。
|
||||||
|
-- 注意:仅当 config 为空时才写入,避免覆盖已有配置。
|
||||||
|
-- ------------------------------------------------------------
|
||||||
|
UPDATE `crawl_source`
|
||||||
|
SET `config` = JSON_OBJECT('incrDays', 30, 'maxItems', 60, 'delayMs', 500, 'minScore', 2)
|
||||||
|
WHERE (`config` IS NULL OR `config` = '' OR `config` = '{}')
|
||||||
|
AND `source_type` = 1
|
||||||
|
AND `enabled` = 1;
|
||||||
Loading…
Reference in New Issue
Block a user