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:
夏犀麟 2026-09-14 01:22:39 +08:00
parent 4b3c00270f
commit ad4567fc01
22 changed files with 3224 additions and 210 deletions

4
.gitignore vendored
View File

@ -36,3 +36,7 @@ data/
.run/
service_test.exe
service_test2*
# 招聘爬虫本地离线验证产物rc_*验证脚本编译出的二进制 + 运行日志不入库
rc_*.log
rc_verify*

View File

@ -1,6 +1,8 @@
# service.xpcool.com 变更记录
> 倒序最新在上格式YYYY-MM-DD | 类型 | 摘要
2026-09-14 | CHG | **归档提交 09-13~09-14 压在工作区的两批改动招聘爬虫健壮性 + 通知记录增强**11 9 **招聘爬虫**新增 `source_config.go``crawl_source.config` 这个 VARCHAR(1000) JSON 的源级抓取参数解析增量窗口/单次上限/分页/限速/自定义标准词**全字段可选解析失败即回退默认**保证历史数据零迁移可用一个脏配置不会拖垮整个调度`keywords.go`链接筛选改评分制URL 形态加分 + 标准词加分 + 排除词大幅减分达阈值入选取代原先URL 形态与文本语义必须同时命中的与逻辑该逻辑会系统性漏抓补充工作人员的通知公开选调公务员简章等标题变体且静默无报错 `keywords_test.go` 单测与 `testdata/``manifest/sql/recruitment/004_crawl_source_status.sql``crawl_source` 最近一次运行状态冗余列规避 `Sources()` 全表扫只增不减的 `crawl_log`随运行时间线性劣化`crawler.go` 在既有两级抓取流程上做锚点抽取/过滤与编码回退的健壮性增强`bark.go` 推送配合调整 **通知模块**`notice.go`/controller/api/dto 扩展通知日志的查询与操作批次号状态重试次数来源耗时详情删除清空`dto/notice_meta.go`312 字典选项元数据`manifest/sql/017_notice_log_enhance.sql`notice_log 扩列 + 新增接口权限幂等可重放 `.gitignore` `rc_*` 挡掉本地验证产物 30MB `rc_verify.exe`设计依据见 `docs/recruitment-crawler-design.md`
2026-09-13 | DOC | 项目整理与全局中文化重写 README.MD原英文且路由陈旧 /api/v1 实际三分组 /api/service/{open,user,admin}补分层约定/RBAC 权限映射/gen dao 流程/本地启动重写 PROJECT_STRUCTURE.md原英文目录树缺 house/recruitment/notice/job/serversecurity标注手写 entity/do/dao 风险hack/hack.mk + hack-cli.mk 注释中文化common/doc.go + common/tools/doc.go 工具清单中文化ip/main.go/cmd.go 英文注释中文化**校验提示与服务层错误信息中文化**19 文件逾 90 api v:"#提示" 中文user/authtools/md5tools/randommenu_manageadminservice gerror.Wrap/response.Error 全部中文admin/adminadmin/loginsystem/{role,menu,menu_manage,login_log}user/authhouse/{community,listing,dashboard,transaction,presale}noticejobserversecurityrecruitment/{recruitment,crawler}jwt.gocontroller/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 项全过

View File

@ -80,35 +80,117 @@ type NoticeRuleDeleteReq struct {
type NoticeRuleDeleteRes struct{}
// ---------- 通知日志 ----------
// ---------- 通知历史记录 ----------
// NoticeLogItem 通知历史记录行:落库字段 + 渠道/事件/类型/分组/接收人等派生展示字段。
type NoticeLogItem struct {
Id uint64 `json:"id"`
RuleId uint64 `json:"ruleId"`
EventType string `json:"eventType"`
ChannelCode string `json:"channelCode"`
UserId uint64 `json:"userId"`
Target string `json:"target"`
Title string `json:"title"`
Body string `json:"body"`
Result int `json:"result"`
Error string `json:"error"`
CreatedAt string `json:"createdAt"`
BatchId string `json:"batchId"` // 批次号(同一次业务触发)
RuleId uint64 `json:"ruleId"` // 规则ID
RuleName string `json:"ruleName"` // 规则名称
EventType string `json:"eventType"` // 事件编码
EventName string `json:"eventName"` // 事件名称
NoticeType string `json:"noticeType"` // 类型编码
TypeName string `json:"typeName"` // 类型名称
Group string `json:"group"` // 分组编码
GroupName string `json:"groupName"` // 分组名称
ChannelCode string `json:"channelCode"` // 渠道编码
ChannelName string `json:"channelName"` // 渠道名称
UserId uint64 `json:"userId"` // 接收用户ID
UserName string `json:"userName"` // 接收人
Target string `json:"target"` // 发送目标
Title string `json:"title"` // 标题
Body string `json:"body"` // 内容
Status int `json:"status"` // 0待发送 1成功 2失败
StatusName string `json:"statusName"` // 状态名称
Result int `json:"result"` // 兼容旧字段 1成功 0失败
Error string `json:"error"` // 错误信息
RetryCount int `json:"retryCount"` // 已重试次数
DurationMs int `json:"durationMs"` // 耗时(毫秒)
Source string `json:"source"` // 触发来源 auto/manual/test
Remark string `json:"remark"` // 备注
CreatedAt string `json:"createdAt"` // 通知时间
}
type NoticeLogListReq struct {
g.Meta `path:"/notice/log/list" method:"post" tags:"Admin/Notice" summary:"通知发送记录分页"`
g.Meta `path:"/notice/log/list" method:"post" tags:"Admin/Notice" summary:"通知历史记录分页"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
EventType string `json:"eventType"`
ChannelCode string `json:"channelCode"`
Result int `json:"result"` // 0全部 1成功 2失败
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
Keyword string `json:"keyword"` // 标题/内容/目标/错误 模糊
NoticeType string `json:"noticeType"` // 类型编码
Group string `json:"group"` // 分组编码
EventType string `json:"eventType"` // 事件编码
ChannelCode string `json:"channelCode"` // 渠道编码
UserId uint64 `json:"userId"` // 接收用户ID
BatchId string `json:"batchId"` // 批次号
Status int `json:"status"` // 0全部 1成功 2失败
Result int `json:"result"` // 兼容旧参数1成功 2失败
DateFrom string `json:"dateFrom"` // 起始时间
DateTo string `json:"dateTo"` // 结束时间
OrderBy string `json:"orderBy"` // createdAt(默认) | durationMs
OrderDir string `json:"orderDir"` // desc(默认) | asc
}
type NoticeLogListRes struct {
List []*NoticeLogItem `json:"list"`
Total int `json:"total"`
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"`
}
// ---------- 测试发送 ----------

View 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 **手写**勿被覆盖

View File

@ -77,25 +77,68 @@ func (c *Controller) RuleDelete(ctx context.Context, req *noticev1.NoticeRuleDel
return &noticev1.NoticeRuleDeleteRes{}, nil
}
// LogList 通知发送记录分页
// LogList 通知历史记录分页(含统计概览)
func (c *Controller) LogList(ctx context.Context, req *noticev1.NoticeLogListReq) (res *noticev1.NoticeLogListRes, err error) {
list, total, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
Page: req.Page, Size: req.Size, EventType: req.EventType,
ChannelCode: req.ChannelCode, Result: req.Result, DateFrom: req.DateFrom, DateTo: req.DateTo,
list, total, stats, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
Page: req.Page, Size: req.Size, Keyword: req.Keyword,
NoticeType: req.NoticeType, Group: req.Group, EventType: req.EventType,
ChannelCode: req.ChannelCode, UserId: req.UserId, BatchId: req.BatchId,
Status: req.Status, Result: req.Result,
DateFrom: req.DateFrom, DateTo: req.DateTo,
OrderBy: req.OrderBy, OrderDir: req.OrderDir,
})
if err != nil {
return nil, err
}
out := make([]*noticev1.NoticeLogItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &noticev1.NoticeLogItem{
Id: v.Id, RuleId: v.RuleId, EventType: v.EventType, ChannelCode: v.ChannelCode,
UserId: v.UserId, Target: v.Target, Title: v.Title, Body: v.Body,
Result: v.Result, Error: v.Error, CreatedAt: v.CreatedAt,
})
out = append(out, toLogItem(&list[i]))
}
return &noticev1.NoticeLogListRes{List: out, Total: total}, nil
res = &noticev1.NoticeLogListRes{List: out, Total: total}
if stats != nil {
res.Stats = &noticev1.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 &noticev1.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 &noticev1.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 &noticev1.NoticeLogClearRes{Deleted: n}, nil
}
// MetaOptions 通知字典选项(渠道/事件/类型/分组)。
func (c *Controller) MetaOptions(ctx context.Context, req *noticev1.NoticeMetaOptionsReq) (res *noticev1.NoticeMetaOptionsRes, err error) {
return &noticev1.NoticeMetaOptionsRes{
Channels: toMetaItems(dto.NoticeChannelOptions()),
Events: toMetaItems(dto.NoticeEventOptions()),
Types: toMetaItems(dto.NoticeTypeOptions()),
Groups: toMetaItems(dto.NoticeGroupOptions()),
}, nil
}
// Test 测试通知发送。
@ -106,3 +149,29 @@ func (c *Controller) Test(ctx context.Context, req *noticev1.NoticeTestReq) (res
}
return &noticev1.NoticeTestRes{Result: ok, Message: msg}, nil
}
// toLogItem dto → API 契约转换。
func toLogItem(v *dto.NoticeLogVO) *noticev1.NoticeLogItem {
return &noticev1.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, &noticev1.NoticeMetaItem{Code: list[i].Code, Name: list[i].Name})
}
return out
}

View File

@ -35,15 +35,21 @@ type NoticeRule struct {
type NoticeLog struct {
g.Meta `orm:"table:notice_log, do:true"`
Id any //
BatchId any // 批次号
RuleId any // 规则ID
EventType any // 事件类型
EventType any // 事件编码
ChannelCode any // 渠道编码
UserId any // 用户ID
Target any // 发送目标
Title any // 标题
Body any // 内容
Result any // 结果
Status any // 0待发送 1成功 2失败
Result any // 1成功 0失败(兼容旧字段)
Error any // 错误
RetryCount any // 已重试次数
DurationMs any // 耗时(毫秒)
Source any // 触发来源
Remark any // 备注
CreatedAt any //
}

View File

@ -56,6 +56,10 @@ type CrawlSource struct {
CronExpr any // 调度表达式
LastSuccessAt any // 最近成功抓取时间
FailCount any // 连续失败次数
LastLogAt any // 最近一次抓取时间(冗余)
LastLogFetched any // 最近一次抓取条数(冗余)
LastLogNew any // 最近一次新增条数(冗余)
LastLogError any // 最近一次错误信息(冗余)
CreatedAt any //
UpdatedAt any //
}

View File

@ -47,30 +47,76 @@ type NoticeRuleInput struct {
Enabled int
}
// NoticeLogVO 通知记录出参。
// NoticeLogVO 通知历史记录出参。
// 除落库字段外,额外补齐「渠道名称/事件名称/类型/分组/接收人」等派生字段,供表格直接展示。
type NoticeLogVO struct {
Id uint64
RuleId uint64
EventType string
ChannelCode string
UserId uint64
Target string
Title string
Body string
Result int
Error string
CreatedAt string
Id uint64 `json:"id"` // 主键
BatchId string `json:"batchId"` // 批次号(同一次业务触发的多条投递共用)
RuleId uint64 `json:"ruleId"` // 规则ID
RuleName string `json:"ruleName"` // 规则名称(派生,规则已删除则为空)
EventType string `json:"eventType"` // 事件编码
EventName string `json:"eventName"` // 事件名称(派生)
NoticeType string `json:"noticeType"` // 类型编码(派生)
TypeName string `json:"typeName"` // 类型名称(派生)
Group string `json:"group"` // 分组编码(派生)
GroupName string `json:"groupName"` // 分组名称(派生)
ChannelCode string `json:"channelCode"` // 渠道编码
ChannelName string `json:"channelName"` // 渠道名称(派生)
UserId uint64 `json:"userId"` // 接收用户ID
UserName string `json:"userName"` // 接收人(昵称/账号,派生)
Target string `json:"target"` // 发送目标设备key/token
Title string `json:"title"` // 标题
Body string `json:"body"` // 内容
Status int `json:"status"` // 0待发送 1成功 2失败
StatusName string `json:"statusName"` // 状态名称(派生)
Result int `json:"result"` // 兼容旧字段1成功 0失败
Error string `json:"error"` // 错误信息
RetryCount int `json:"retryCount"` // 已重试次数
DurationMs int `json:"durationMs"` // 渠道接口耗时(毫秒)
Source string `json:"source"` // 触发来源auto/manual/test
Remark string `json:"remark"` // 备注(如测试说明)
CreatedAt string `json:"createdAt"` // 通知时间
}
// NoticeLogFilter 通知记录筛选。
// NoticeLogFilter 通知历史记录筛选(分页 + 多维条件)
type NoticeLogFilter struct {
Page int
Size int
EventType string
ChannelCode string
Result int // 0全部 1成功 2失败
DateFrom string
DateTo string
Page int // 页码
Size int // 每页条数
Keyword string // 关键字:标题/内容/目标/错误 模糊
NoticeType string // 类型编码job/security/...
Group string // 分组编码auto_job/server/...
EventType string // 事件编码
ChannelCode string // 渠道编码
UserId uint64 // 接收用户ID
BatchId string // 批次号
Status int // 0全部 1成功 2失败兼容旧的 result 字段)
Result int // 兼容旧参数1成功 2失败
DateFrom string // 起始时间yyyy-MM-dd HH:mm:ss
DateTo string // 结束时间
OrderBy string // createdAt(默认) | durationMs
OrderDir string // desc(默认) | asc
}
// 构造并归一化Result 为兼容字段,优先级低于 Status。
func (f *NoticeLogFilter) Normalize() {
if f.Page <= 0 {
f.Page = 1
}
if f.Size <= 0 {
f.Size = 10
}
if f.Size > 100 {
f.Size = 100
}
if f.Status == 0 && f.Result > 0 {
f.Status = f.Result
}
if f.OrderDir != "asc" {
f.OrderDir = "desc"
}
if f.OrderBy != "durationMs" {
f.OrderBy = "createdAt"
}
}
// RenderTemplate 简单模板渲染:将 {{key}} 替换为 vars 中对应值。

View 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" // BarkiOS 推送)
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
}

View File

@ -27,19 +27,25 @@ type NoticeRule struct {
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// NoticeLog 通知发送记录
// NoticeLog 通知发送记录(通知历史记录)
type NoticeLog struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
BatchId string `json:"batchId" orm:"batch_id" description:"批次号(同一次业务触发)"`
RuleId uint64 `json:"ruleId" orm:"rule_id" description:"规则ID"`
EventType string `json:"eventType" orm:"event_type" description:"事件类型"`
EventType string `json:"eventType" orm:"event_type" description:"事件编码"`
ChannelCode string `json:"channelCode" orm:"channel_code" description:"渠道编码"`
UserId uint64 `json:"userId" orm:"user_id" description:"接收用户ID"`
Target string `json:"target" orm:"target" description:"发送目标"`
Title string `json:"title" orm:"title" description:"标题"`
Body string `json:"body" orm:"body" description:"内容"`
Result int `json:"result" orm:"result" description:"1成功 0失败"`
Status int `json:"status" orm:"status" description:"0待发送 1成功 2失败"`
Result int `json:"result" orm:"result" description:"1成功 0失败(兼容旧字段)"`
Error string `json:"error" orm:"error" description:"错误信息"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
RetryCount int `json:"retryCount" orm:"retry_count" description:"已重试次数"`
DurationMs int `json:"durationMs" orm:"duration_ms" description:"渠道接口耗时(毫秒)"`
Source string `json:"source" orm:"source" description:"触发来源 auto/manual/test"`
Remark string `json:"remark" orm:"remark" description:"备注"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"通知时间"`
}
// AutoJob 自动任务定义。

View File

@ -2,6 +2,8 @@
// 字段命名对齐 service 现有约定json 用驼峰orm 用下划线。
package entity
import "github.com/gogf/gf/v2/os/gtime"
// RecruitmentInfo 招聘考试公告主表。
type RecruitmentInfo struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
@ -49,6 +51,10 @@ type CrawlSource struct {
CronExpr string `json:"cronExpr" orm:"cron_expr" description:"调度表达式(可选)"`
LastSuccessAt string `json:"lastSuccessAt" orm:"last_success_at" description:"最近成功抓取时间"`
FailCount int `json:"failCount" orm:"fail_count" description:"连续失败次数"`
LastLogAt *gtime.Time `json:"lastLogAt" orm:"last_log_at" description:"最近一次抓取时间(冗余)"`
LastLogFetched int `json:"lastLogFetched" orm:"last_log_fetched" description:"最近一次抓取条数(冗余)"`
LastLogNew int `json:"lastLogNew" orm:"last_log_new" description:"最近一次新增条数(冗余)"`
LastLogError string `json:"lastLogError" orm:"last_log_error" description:"最近一次错误信息(冗余)"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}

View File

@ -1,4 +1,4 @@
// Package notice 提供统一通知服务(渠道/规则/发送/日志)。
// Package notice 提供统一通知服务(渠道/规则/发送/历史记录)。
// 发送实现Bark 走路径式 keyPOST {baseUrl}/{deviceKey} JSONpushplus 走官方 send 接口。
package notice
@ -8,17 +8,23 @@ import (
"strings"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/guid"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/library/response"
"service.xpcool.com/internal/model/do"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// gdbModel 查询模型别名,避免在多处书写完整包路径。
type gdbModel = *gdb.Model
// INotice 统一通知服务接口。
type INotice interface {
ChannelList(context.Context) ([]dto.NoticeChannelVO, error)
@ -26,7 +32,11 @@ type INotice interface {
RuleList(context.Context, string) ([]dto.NoticeRuleVO, error)
RuleSave(context.Context, dto.NoticeRuleInput) (uint64, error)
RuleDelete(context.Context, uint64) error
LogList(context.Context, dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error)
// LogList 通知历史记录分页(返回列表、总数与统计概览)。
LogList(context.Context, dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, *dto.NoticeLogStats, error)
LogDetail(context.Context, uint64) (*dto.NoticeLogVO, error)
LogDelete(context.Context, []uint64) (int, error)
LogClear(context.Context, int, string, string) (int, error)
Send(context.Context, string, map[string]string) error // 按启用规则发送通知(事件→渠道→人员)
Test(context.Context, uint64, string, string, []uint64, string, string) (bool, string, error)
}
@ -64,9 +74,9 @@ func (s *notice) ChannelList(ctx context.Context) ([]dto.NoticeChannelVO, error)
}
func (s *notice) ChannelSave(ctx context.Context, in dto.NoticeChannelInput) (uint64, error) {
data := map[string]interface{}{
"code": in.Code, "name": in.Name, "enabled": in.Enabled,
"config": in.Config, "remark": in.Remark,
data := do.NoticeChannel{
Code: in.Code, Name: in.Name, Enabled: in.Enabled,
Config: in.Config, Remark: in.Remark,
}
if in.Id > 0 {
if _, err := dao.NoticeChannel.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
@ -104,10 +114,10 @@ func (s *notice) RuleList(ctx context.Context, eventType string) ([]dto.NoticeRu
}
func (s *notice) RuleSave(ctx context.Context, in dto.NoticeRuleInput) (uint64, error) {
data := map[string]interface{}{
"name": in.Name, "event_type": in.EventType,
"channel_codes": toJSON(in.ChannelCodes), "user_ids": toJSON(in.UserIds),
"title_template": in.TitleTemplate, "body_template": in.BodyTemplate, "enabled": in.Enabled,
data := do.NoticeRule{
Name: in.Name, EventType: in.EventType,
ChannelCodes: toJSON(in.ChannelCodes), UserIds: toJSON(in.UserIds),
TitleTemplate: in.TitleTemplate, BodyTemplate: in.BodyTemplate, Enabled: in.Enabled,
}
if in.Id > 0 {
if _, err := dao.NoticeRule.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
@ -129,18 +139,81 @@ func (s *notice) RuleDelete(ctx context.Context, id uint64) error {
return nil
}
// ---------------- 日志 ----------------
// ---------------- 历史记录 ----------------
func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error) {
// LogList 通知历史记录分页:支持关键字、类型、分组、事件、渠道、接收人、状态、时间范围、排序。
func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, *dto.NoticeLogStats, error) {
f.Normalize()
m := s.logQuery(ctx, f)
total, err := m.Clone().Count()
if err != nil {
return nil, 0, nil, gerror.Wrap(err, "统计通知历史记录总数失败")
}
// 统计概览(成功/失败/成功率),与列表使用同一套筛选条件。
stats, err := s.logStats(ctx, f, total)
if err != nil {
return nil, 0, nil, err
}
var list []entity.NoticeLog
orderField := "created_at"
if f.OrderBy == "durationMs" {
orderField = "duration_ms"
}
if f.OrderDir == "asc" {
err = m.Clone().Page(f.Page, f.Size).OrderAsc(orderField).OrderAsc("id").Scan(&list)
} else {
err = m.Clone().Page(f.Page, f.Size).OrderDesc(orderField).OrderDesc("id").Scan(&list)
}
if err != nil {
return nil, 0, nil, gerror.Wrap(err, "查询通知历史记录列表失败")
}
out := s.buildLogVOs(ctx, list)
return out, total, stats, nil
}
// logQuery 组装历史记录筛选条件(列表与统计共用,保证口径一致)。
func (s *notice) logQuery(ctx context.Context, f dto.NoticeLogFilter) gdbModel {
m := dao.NoticeLog.Ctx(ctx)
if f.Keyword != "" {
kw := "%" + f.Keyword + "%"
m = m.Where("title LIKE ? OR body LIKE ? OR target LIKE ? OR error LIKE ?", kw, kw, kw, kw)
}
if f.EventType != "" {
m = m.Where("event_type", f.EventType)
} else if f.Group != "" {
if events := dto.NoticeGroupEventCodes(f.Group); len(events) > 0 {
m = m.WhereIn("event_type", events)
} else {
// 未在字典中的分组(如 system则反向排除已知事件。
m = m.WhereNotIn("event_type", dto.KnownEventCodes())
}
} else if f.NoticeType != "" {
switch f.NoticeType {
case dto.NoticeTypeSystem:
m = m.WhereNotIn("event_type", dto.KnownEventCodes())
default:
if events := dto.NoticeTypeEventCodes(f.NoticeType); len(events) > 0 {
m = m.WhereIn("event_type", events)
}
}
}
if f.ChannelCode != "" {
m = m.Where("channel_code", f.ChannelCode)
}
if f.Result > 0 {
m = m.Where("result", f.Result)
if f.UserId > 0 {
m = m.Where("user_id", f.UserId)
}
if f.BatchId != "" {
m = m.Where("batch_id", f.BatchId)
}
if f.Status > 0 {
if f.Status == dto.NoticeStatusSuccess {
// 成功status=1 或历史数据 result=1。
m = m.Where("(status = 1 OR (status = 0 AND result = 1))")
} else {
// 失败status=2 或历史数据 result=0且非待发送
m = m.Where("(status = 2 OR (status = 0 AND result = 0))")
}
}
if f.DateFrom != "" {
m = m.WhereGTE("created_at", f.DateFrom)
@ -148,28 +221,181 @@ func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.Noti
if f.DateTo != "" {
m = m.WhereLTE("created_at", f.DateTo)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "统计通知日志总数失败")
return m
}
var list []entity.NoticeLog
if err = m.Page(f.Page, f.Size).OrderDesc("id").Scan(&list); 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
}
stats.Failed = total - stats.Success
if stats.Failed < 0 {
stats.Failed = 0
}
if total > 0 {
stats.SuccessRate = int(float64(stats.Success)*100/float64(total) + 0.5)
}
return stats, nil
}
// buildLogVOs 实体转 VO并批量补齐规则名 / 接收人 等派生字段(避免 N+1
func (s *notice) buildLogVOs(ctx context.Context, list []entity.NoticeLog) []dto.NoticeLogVO {
out := make([]dto.NoticeLogVO, 0, len(list))
for _, v := range list {
out = append(out, dto.NoticeLogVO{
Id: v.Id, RuleId: v.RuleId, EventType: v.EventType, ChannelCode: v.ChannelCode,
UserId: v.UserId, Target: v.Target, Title: v.Title, Body: v.Body,
Result: v.Result, Error: v.Error, CreatedAt: v.CreatedAt,
})
if len(list) == 0 {
return out
}
return out, total, nil
// 批量取规则名称
ruleIds := make([]uint64, 0, len(list))
userIds := make([]uint64, 0, len(list))
seenRule := map[uint64]struct{}{}
seenUser := map[uint64]struct{}{}
for _, v := range list {
if v.RuleId > 0 {
if _, ok := seenRule[v.RuleId]; !ok {
seenRule[v.RuleId] = struct{}{}
ruleIds = append(ruleIds, v.RuleId)
}
}
if v.UserId > 0 {
if _, ok := seenUser[v.UserId]; !ok {
seenUser[v.UserId] = struct{}{}
userIds = append(userIds, v.UserId)
}
}
}
ruleNames := map[uint64]string{}
if len(ruleIds) > 0 {
var rules []entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).WhereIn("id", ruleIds).Scan(&rules); err == nil {
for _, r := range rules {
ruleNames[r.Id] = r.Name
}
}
}
userNames := map[uint64]string{}
if len(userIds) > 0 {
var users []entity.AdminUser
if err := dao.AdminUser.Ctx(ctx).WhereIn("id", userIds).Fields("id, username, nickname").Scan(&users); err == nil {
for _, u := range users {
name := u.Nickname
if name == "" {
name = u.Username
}
userNames[u.Id] = name
}
}
}
for _, v := range list {
out = append(out, toLogVO(v, ruleNames[v.RuleId], userNames[v.UserId]))
}
return out
}
// toLogVO 单条实体转 VO补齐派生名称字段
func toLogVO(v entity.NoticeLog, ruleName, userName string) dto.NoticeLogVO {
noticeType := dto.NoticeEventToType(v.EventType)
group := dto.NoticeTypeToGroup(noticeType)
// status 兼容历史数据:仅有 result 时按 result 推导。
status := v.Status
if status == 0 && v.Result == 1 {
status = dto.NoticeStatusSuccess
} else if status == 0 && v.Result == 0 && v.CreatedAt != "" && v.Error != "" {
status = dto.NoticeStatusFailed
}
return dto.NoticeLogVO{
Id: v.Id, BatchId: v.BatchId, RuleId: v.RuleId, RuleName: ruleName,
EventType: v.EventType, EventName: dto.NoticeEventName(v.EventType),
NoticeType: noticeType, TypeName: dto.NoticeTypeName(noticeType),
Group: group, GroupName: dto.NoticeGroupName(group),
ChannelCode: v.ChannelCode, ChannelName: dto.NoticeChannelName(v.ChannelCode),
UserId: v.UserId, UserName: userName, Target: v.Target,
Title: v.Title, Body: v.Body,
Status: status, StatusName: dto.NoticeStatusName(status),
Result: v.Result, Error: v.Error,
RetryCount: v.RetryCount, DurationMs: v.DurationMs,
Source: v.Source, Remark: v.Remark, CreatedAt: v.CreatedAt,
}
}
// LogDetail 单条历史记录详情。
func (s *notice) LogDetail(ctx context.Context, id uint64) (*dto.NoticeLogVO, error) {
var v entity.NoticeLog
if err := dao.NoticeLog.Ctx(ctx).Where("id", id).Scan(&v); err != nil {
return nil, gerror.Wrap(err, "查询通知历史详情失败")
}
if v.Id == 0 {
return nil, response.Error(consts.CodeInvalidParam, "通知记录不存在")
}
ruleName := ""
if v.RuleId > 0 {
var r entity.NoticeRule
_ = dao.NoticeRule.Ctx(ctx).Where("id", v.RuleId).Scan(&r)
ruleName = r.Name
}
userName := ""
if v.UserId > 0 {
var u entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", v.UserId).Fields("id, username, nickname").Scan(&u)
userName = u.Nickname
if userName == "" {
userName = u.Username
}
}
vo := toLogVO(v, ruleName, userName)
return &vo, nil
}
// LogDelete 批量删除历史记录,返回删除条数。
func (s *notice) LogDelete(ctx context.Context, ids []uint64) (int, error) {
if len(ids) == 0 {
return 0, response.Error(consts.CodeInvalidParam, "请选择要删除的记录")
}
r, err := dao.NoticeLog.Ctx(ctx).WhereIn("id", ids).Delete()
if err != nil {
return 0, gerror.Wrap(err, "删除通知历史记录失败")
}
n, _ := r.RowsAffected()
return int(n), nil
}
// LogClear 清空历史记录KeepDays>0 时保留最近 N 天,否则按 DateFrom/DateTo 范围删除(都为空则全清)。
func (s *notice) LogClear(ctx context.Context, keepDays int, dateFrom, dateTo string) (int, error) {
m := dao.NoticeLog.Ctx(ctx)
switch {
case keepDays > 0:
cut := gtime.Now().AddDate(0, 0, -keepDays).Format("Y-m-d H:i:s")
m = m.WhereLT("created_at", cut)
case dateFrom != "" || dateTo != "":
if dateFrom != "" {
m = m.WhereGTE("created_at", dateFrom)
}
if dateTo != "" {
m = m.WhereLTE("created_at", dateTo)
}
default:
m = m.Where("id > ?", 0) // 全清
}
r, err := m.Delete()
if err != nil {
return 0, gerror.Wrap(err, "清空通知历史记录失败")
}
n, _ := r.RowsAffected()
return int(n), nil
}
// ---------------- 发送 ----------------
// Send 按事件类型查启用规则,向每个渠道的每个接收用户发送,并记录日志。
// 同一次 Send 产生的多条投递共用同一批次号batchId便于按批次追溯。
func (s *notice) Send(ctx context.Context, eventType string, vars map[string]string) error {
var rules []entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("event_type", eventType).Where("enabled", 1).Scan(&rules); err != nil {
@ -178,6 +404,7 @@ func (s *notice) Send(ctx context.Context, eventType string, vars map[string]str
if len(rules) == 0 {
return nil // 未配置规则 = 静默
}
batchId := newBatchId()
for _, r := range rules {
channels := parseStrArray(r.ChannelCodes)
userIds := parseUintArray(r.UserIds)
@ -185,19 +412,36 @@ func (s *notice) Send(ctx context.Context, eventType string, vars map[string]str
body := dto.RenderTemplate(r.BodyTemplate, vars)
for _, channel := range channels {
for _, uid := range userIds {
s.deliver(ctx, r.Id, eventType, channel, uid, title, body)
s.deliver(ctx, deliverInput{
BatchId: batchId, RuleId: r.Id, EventType: eventType,
Channel: channel, UserId: uid, Title: title, Body: body,
Source: "auto",
})
}
}
}
return nil
}
// deliver 单条投递:取用户渠道凭据 → 发送 → 记日志。
func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel string, userId uint64, title, body string) {
// deliverInput 单条投递入参。
type deliverInput struct {
BatchId string
RuleId uint64
EventType string
Channel string
UserId uint64
Title string
Body string
Source string // auto/manual/test
Remark string
}
// deliver 单条投递:取用户渠道凭据 → 发送 → 记日志(含耗时与来源)。
func (s *notice) deliver(ctx context.Context, in deliverInput) {
var user entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", userId).Scan(&user)
_ = dao.AdminUser.Ctx(ctx).Where("id", in.UserId).Scan(&user)
var target string
switch channel {
switch in.Channel {
case "bark":
target = user.BarkDeviceId
case "pushplus":
@ -205,15 +449,19 @@ func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel
default:
target = ""
}
ok, errMsg := s.sendByChannel(ctx, channel, target, title, body)
result := 0
start := time.Now()
ok, errMsg := s.sendByChannel(ctx, in.Channel, target, in.Title, in.Body)
duration := int(time.Since(start).Milliseconds())
status := dto.NoticeStatusFailed
if ok {
result = 1
status = dto.NoticeStatusSuccess
}
_, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
"rule_id": ruleId, "event_type": eventType, "channel_code": channel,
"user_id": userId, "target": target, "title": title, "body": body,
"result": result, "error": errMsg,
_, _ = dao.NoticeLog.Ctx(ctx).Data(do.NoticeLog{
BatchId: in.BatchId, RuleId: in.RuleId, EventType: in.EventType,
ChannelCode: in.Channel, UserId: in.UserId, Target: target,
Title: in.Title, Body: in.Body,
Status: status, Result: boolToInt(ok), Error: errMsg,
DurationMs: duration, Source: in.Source, Remark: in.Remark,
}).Insert()
}
@ -249,9 +497,10 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
var out struct {
Code int `json:"code"`
}
_ = json.Unmarshal(resp.ReadAll(), &out)
respBody := resp.ReadAllString()
_ = json.Unmarshal([]byte(respBody), &out)
if out.Code != 200 {
return false, "bark code " + resp.ReadAllString()
return false, "bark code " + respBody
}
return true, ""
case "pushplus":
@ -268,9 +517,10 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
var out struct {
Code int `json:"code"`
}
_ = json.Unmarshal(resp.ReadAll(), &out)
respBody := resp.ReadAllString()
_ = json.Unmarshal([]byte(respBody), &out)
if out.Code != 200 {
return false, "pushplus code " + resp.ReadAllString()
return false, "pushplus code " + respBody
}
return true, ""
}
@ -279,6 +529,7 @@ func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body
// Test 测试发送:优先按规则;否则手动指定渠道+目标。
func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target string, userIds []uint64, title, body string) (bool, string, error) {
batchId := newBatchId()
if ruleId > 0 {
var r entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("id", ruleId).Scan(&r); err != nil || r.Id == 0 {
@ -286,7 +537,11 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
}
for _, ch := range parseStrArray(r.ChannelCodes) {
for _, uid := range parseUintArray(r.UserIds) {
s.deliver(ctx, r.Id, r.EventType, ch, uid, title, body)
s.deliver(ctx, deliverInput{
BatchId: batchId, RuleId: r.Id, EventType: dto.NoticeEventTest,
Channel: ch, UserId: uid, Title: title, Body: body,
Source: "test", Remark: "按规则测试",
})
}
}
return true, "已按规则投递", nil
@ -295,9 +550,11 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
if channelCode == "" {
return false, "", response.Error(consts.CodeInvalidParam, "请指定渠道")
}
userId := uint64(0)
if target == "" && len(userIds) > 0 {
userId = userIds[0]
var user entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", userIds[0]).Scan(&user)
_ = dao.AdminUser.Ctx(ctx).Where("id", userId).Scan(&user)
if channelCode == "bark" {
target = user.BarkDeviceId
} else if channelCode == "pushplus" {
@ -307,17 +564,30 @@ func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target st
if target == "" {
return false, "", response.Error(consts.CodeInvalidParam, "目标凭据为空")
}
start := time.Now()
ok, msg := s.sendByChannel(ctx, channelCode, target, title, body)
_, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
"rule_id": 0, "event_type": "test", "channel_code": channelCode,
"user_id": 0, "target": target, "title": title, "body": body,
"result": boolToInt(ok), "error": msg,
duration := int(time.Since(start).Milliseconds())
status := dto.NoticeStatusFailed
if ok {
status = dto.NoticeStatusSuccess
}
_, _ = dao.NoticeLog.Ctx(ctx).Data(do.NoticeLog{
BatchId: batchId, RuleId: 0, EventType: dto.NoticeEventTest,
ChannelCode: channelCode, UserId: userId, Target: target,
Title: title, Body: body,
Status: status, Result: boolToInt(ok), Error: msg,
DurationMs: duration, Source: "test", Remark: "手动测试",
}).Insert()
return ok, msg, nil
}
// ---------------- 工具 ----------------
// newBatchId 生成投递批次号(同一次业务触发共用一个)。
func newBatchId() string {
return guid.S()[:16]
}
func parseStrArray(s string) []string {
var out []string
_ = json.Unmarshal([]byte(s), &out)

View File

@ -89,13 +89,13 @@ func barkPush(ctx context.Context, deviceKey, title, body string) (bool, string,
func sendDailyDigest(ctx context.Context) {
var subs []entity.PushSubscription
if err := dao.PushSubscription.Ctx(ctx).Where("enabled", 1).Scan(&subs); err != nil {
g.Log().Errorf(ctx, "load subscriptions failed: %v", err)
g.Log().Errorf(ctx, "加载推送订阅失败: %v", err)
return
}
if len(subs) == 0 {
def := defaultSubscription(ctx)
if def == nil {
g.Log().Infof(ctx, "no subscription and no default bark device key, skip digest")
g.Log().Infof(ctx, "无启用订阅且未配置兜底设备密钥,跳过早报推送")
return
}
subs = []entity.PushSubscription{*def}
@ -125,7 +125,7 @@ func buildDigest(ctx context.Context, sub entity.PushSubscription) (string, stri
}
var list []dto.RecruitmentInfoVO
if err := m.Fields("title, category, region, deadline").OrderDesc("publish_date").Scan(&list); err != nil {
g.Log().Errorf(ctx, "build digest query failed: %v", err)
g.Log().Errorf(ctx, "早报内容查询失败: %v", err)
return "", ""
}
if sub.OnlyNew == 1 && len(list) == 0 {
@ -171,6 +171,53 @@ func recordPushLog(ctx context.Context, subId uint64, title, body string, ok boo
SubscriptionId: subId, PushAt: gtime.Now(), Title: title, Body: body,
Result: res, Error: msg, CreatedAt: gtime.Now(),
}).Insert(); err != nil {
g.Log().Errorf(ctx, "write push log failed: %v", err)
g.Log().Errorf(ctx, "写入推送日志失败: %v", err)
}
}
// ---------- 数据源告警 ----------
// notifySourceFailing 推送「数据源连续失败」预警(尚未禁用,提示人工关注)。
func notifySourceFailing(ctx context.Context, src entity.CrawlSource, failCount int, errMsg string) {
title := "抓取源预警 · " + src.Name
body := fmt.Sprintf("数据源「%s」已连续失败 %d 次,暂未禁用。\n最近错误%s",
src.Name, failCount, truncateStr(errMsg, 200))
sendAlert(ctx, title, body)
}
// notifySourceDisabled 推送「数据源已自动禁用」告警。
func notifySourceDisabled(ctx context.Context, src entity.CrawlSource, failCount int, errMsg string) {
title := "抓取源已禁用 · " + src.Name
body := fmt.Sprintf("数据源「%s」连续失败 %d 次,已自动禁用。\n最近错误%s\n请在后台确认后手动重新启用。",
src.Name, failCount, truncateStr(errMsg, 200))
sendAlert(ctx, title, body)
}
// sendAlert 向所有启用订阅(或兜底密钥)发送一条系统告警。
// 与早报不同,告警不受订阅的地区/分类过滤影响——运维信息应送达全部订阅者。
func sendAlert(ctx context.Context, title, body string) {
keys := make([]string, 0, 4)
var subs []entity.PushSubscription
if err := dao.PushSubscription.Ctx(ctx).Where("enabled", 1).Scan(&subs); err == nil {
for _, s := range subs {
if s.DeviceKey != "" {
keys = append(keys, s.DeviceKey)
}
}
}
// 库内无订阅时回退到环境变量兜底密钥。
if len(keys) == 0 {
if k := defaultDeviceKey(ctx); k != "" {
keys = append(keys, k)
}
}
if len(keys) == 0 {
g.Log().Warningf(ctx, "无可用设备密钥,告警未送达:%s", title)
return
}
for _, k := range keys {
if ok, msg, _ := barkPush(ctx, k, title, body); !ok {
g.Log().Warningf(ctx, "告警推送失败 device=%s: %s", k, msg)
}
}
}

View File

@ -70,7 +70,12 @@ func crawlerRun(ctx context.Context, src entity.CrawlSource, force bool) dto.Cra
}
// genericStaticCrawl 通用静态列表抓取:取列表页锚点 → 逐条抓详情 → 抽取字段。
//
// 抓取行为(增量窗口、详情上限、限速、词表、阈值)全部来自 crawl_source.config
// 未配置则走内置默认值,保证历史数据无需迁移即可继续工作。
func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.RecruitmentInput, error) {
cfg := parseSourceConfig(src.Config)
listURL := joinURL(src.BaseUrl, src.ListPath)
if listURL == "" {
listURL = src.BaseUrl
@ -79,32 +84,39 @@ func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool)
if err != nil {
return nil, err
}
g.Log().Warningf(ctx, "[recruit-debug] src=%d listURL=%s htmlLen=%d", src.Id, listURL, len(html))
raw := extractAnchors(html, listURL)
anchors := filterArticleAnchors(raw)
g.Log().Warningf(ctx, "[recruit-debug] src=%d rawAnchors=%d filtered=%d", src.Id, len(raw), len(anchors))
anchors := filterArticleAnchors(raw, cfg)
g.Log().Debugf(ctx, "抓取列表页 src=%d url=%s 锚点=%d 选中=%d", src.Id, listURL, len(raw), len(anchors))
limit := cfg.maxItems(force)
incrDays := cfg.incrDays()
var out []dto.RecruitmentInput
limit := 60
if force {
limit = 300 // 全量回溯放宽上限
}
dateMissing := 0 // 未能识别发布日期的条数,用于在日志中告警
for _, a := range anchors {
if len(out) >= limit {
break
}
if cfg.DelayMs > 0 {
// 限速:政府站对高频访问敏感,按源配置间隔。
time.Sleep(time.Duration(cfg.DelayMs) * time.Millisecond)
}
dHtml, e := fetchHTML(ctx, a.URL)
if e != nil {
continue
}
title, content, pub, dl, ex := extractDetail(dHtml)
title, content, pub, dl, ex := extractDetail(dHtml, cfg)
if title == "" {
title = a.Text
}
pubDate := normalizeDate(pub)
// 增量模式:跳过 30 天前的公告,避免无效回扫。
if pubDate == "" {
dateMissing++
}
// 增量模式跳过窗口期之外的历史公告避免无效回扫。force 时忽略窗口。
if !force && pubDate != "" {
if t, e2 := time.Parse("2006-01-02", pubDate); e2 == nil {
if time.Since(t) > 30*24*time.Hour {
if time.Since(t) > time.Duration(incrDays)*24*time.Hour {
continue
}
}
@ -123,6 +135,10 @@ func genericStaticCrawl(ctx context.Context, src entity.CrawlSource, force bool)
Status: 0,
})
}
if dateMissing > 0 {
// 发布日期识别失败会影响增量判断与趋势统计,需显式暴露而非静默。
g.Log().Warningf(ctx, "源 %d 有 %d 条未识别发布日期,已置空(建议检查 datePatterns 配置)", src.Id, dateMissing)
}
return out, nil
}
@ -145,6 +161,7 @@ func spaCrawl(ctx context.Context, src entity.CrawlSource, force bool) ([]dto.Re
if len(extractAnchors(html, listURL)) == 0 {
return nil, gerror.New("SPA 无服务端渲染内容,需接入底层 JSON 接口阶段0 POC")
}
// 有服务端渲染锚点时,退回通用静态抓取(内部会读取 config 适配)。
return genericStaticCrawl(ctx, src, force)
}
@ -193,12 +210,13 @@ func walkJSON(node any, out *[]dto.RecruitmentInput) {
// 而 Go 客户端可能被 TLS 指纹级 WAF 拦截返回无内容挑战页),失败再回退 Go 客户端。
func fetchHTML(ctx context.Context, u string) (string, error) {
if body, e := fetchWithCurl(u); e == nil && looksLikeHtml(body) && len(body) >= 3000 {
g.Log().Warningf(ctx, "[recruit-debug] curl ok len=%d url=%s", len(body), u)
g.Log().Debugf(ctx, "curl 抓取成功 len=%d url=%s", len(body), u)
return body, nil
} else if e != nil {
g.Log().Warningf(ctx, "[recruit-debug] curl failed: %v url=%s", e, u)
// curl 不可用(如容器未装 curl或执行失败属预期回退场景用 Debug 记录。
g.Log().Debugf(ctx, "curl 抓取未命中,回退 Go 客户端: %v url=%s", e, u)
} else {
g.Log().Warningf(ctx, "[recruit-debug] curl suspicious len=%d url=%s", len(body), u)
g.Log().Debugf(ctx, "curl 返回内容可疑len=%d回退 Go 客户端 url=%s", len(body), u)
}
client := g.Client()
client.SetTimeout(15 * time.Second)
@ -215,10 +233,10 @@ func fetchHTML(ctx context.Context, u string) (string, error) {
}
body := resp.ReadAllString()
if !looksLikeHtml(body) || len(body) < 3000 {
g.Log().Warningf(ctx, "[recruit-debug] go-fallback suspicious len=%d url=%s", len(body), u)
return "", gerror.Newf("页面内容可疑(长度=%d%s", len(body), u)
// Go 客户端被 TLS 指纹级 WAF 拦截时会返回无内容的挑战页,这里显式报错便于排查。
return "", gerror.Newf("页面内容可疑(长度=%d,疑似被 WAF 拦截%s", len(body), u)
}
g.Log().Warningf(ctx, "[recruit-debug] go-fallback ok len=%d url=%s", len(body), u)
g.Log().Debugf(ctx, "Go 客户端抓取成功 len=%d url=%s", len(body), u)
return body, nil
}
@ -262,8 +280,24 @@ var (
dateRe = regexp.MustCompile(`(\d{4})[-/年.](\d{1,2})[-/月.](\d{1,2})`)
inlineJSONRe = regexp.MustCompile(`(?is)(?:__NEXT_DATA__|__INITIAL_STATE__|window\.__[A-Z_]+)\s*=\s*(\{[\s\S]*?\})\s*;?`)
spaceRe = regexp.MustCompile(`\s+`)
// recruitKw 招聘类公告常见语义词,用于从政府站链接中识别公告条目。
recruitKw = regexp.MustCompile(`招聘|招考|公招|选聘|引进|人才|公告|公示|录用|录取|面试|笔试|报名|选调|遴选|拟聘|聘用|招募|招录|考试|考录|体检|考察|资格复审|递补`)
// yearPathRe 匹配 URL 路径中的年份(如 /2026/ 或 /202609/),用于判断内容页形态。
yearPathRe = regexp.MustCompile(`/(20\d{2})[/_]?(\d{2})?/`)
// imgAltRe 抽取 <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 + 文本)。
@ -281,15 +315,27 @@ func extractAnchors(html, base string) []anchor {
strings.HasPrefix(href, "#") || strings.HasPrefix(href, "mailto:") {
continue
}
inner := strings.TrimSpace(stripTags(block[1]))
// 注意stripTags 会把 <img> 替换为空白,图片锚点(如仅含
// <img src="ysqgk3.png" title="ysqgk3.png">)经去标签后 inner 为空,
// 随后的 title 属性回退会取到图片文件名,从而绕过语义词表。
// 故先尝试用 img 的 alt 作为文本,再回退 <a> 的 title最后统一做
// 「图片文件名」筛除,避免这类噪声进入评分。
inner := strings.TrimSpace(collapseSpace(stripTags(block[1])))
text := inner
if text == "" || isImageFileName(text) {
text = firstAttr(block[1], imgAltRe)
}
// 文本过短时回退到 <a> 的 title 属性(政府站常把真实标题放这里)。
if len([]rune(text)) < 4 {
if tm := titleAttrRe.FindStringSubmatch(full); len(tm) >= 2 {
t := strings.TrimSpace(tm[1])
if len([]rune(text)) < 4 && t != "" {
t := strings.TrimSpace(collapseSpace(tm[1]))
if t != "" {
text = t
}
}
if text == "" {
}
// 最终筛除:图片文件名不是有效标题(其 alt/title 也常是文件名)。
if text == "" || isImageFileName(text) {
continue
}
abs := resolveURL(base, href)
@ -302,23 +348,22 @@ func extractAnchors(html, base string) []anchor {
}
// filterArticleAnchors 过滤导航/无用链接,保留疑似招聘公告条目并去重。
func filterArticleAnchors(as []anchor) []anchor {
//
// 采用评分制(见 keywords.go 的 scoreAnchor不再要求 URL 与文本「同时」命中,
// 从而修复「补充工作人员的通知」「公开选调简章」等标题变体被漏抓的问题。
// 词表与阈值来自数据源配置crawl_source.config未配置则用内置默认。
func filterArticleAnchors(as []anchor, cfg SourceConfig) []anchor {
incWords := cfg.includeWordsOrDefault()
excWords := cfg.excludeWordsOrDefault()
minScore := cfg.minScore()
out := make([]anchor, 0, len(as))
for _, a := range as {
text := strings.TrimSpace(a.Text)
if len([]rune(text)) < 4 {
continue
}
u := strings.ToLower(a.URL)
// URL 形态线索:静态详情页、政务公开/人事招考栏目、含年份。
urlHint := strings.Contains(u, ".html") || strings.Contains(u, ".shtml") ||
strings.Contains(u, ".php") || strings.Contains(u, "/tzgg") ||
strings.Contains(u, "/rszk") || strings.Contains(u, "/rsxx") ||
strings.Contains(u, "/zfxx") || dateRe.MatchString(a.URL)
// 文本语义线索:政府站公告标题常含这些词(必须命中,过滤"市长信箱/重点领域"等非招聘页)。
textHint := recruitKw.MatchString(text) || recruitKw.MatchString(a.URL)
// 同时满足"内容页形态"与"招聘语义",避免误抓栏目/互动页。
if urlHint && textHint {
if scoreAnchor(a, incWords, excWords, minScore) {
out = append(out, a)
}
}
@ -334,17 +379,26 @@ func filterArticleAnchors(as []anchor) []anchor {
return uniq
}
// extractDetail 从详情页抽取标题/正文/发布日期/报名截止/笔试日期(启发式,可按源调优)。
func extractDetail(html string) (title, content, publishDate, deadline, examDate string) {
// extractDetail 从详情页抽取标题/正文/发布日期/报名截止/笔试日期。
//
// 发布日期采用「分段抽取」策略(修复原 firstDate 取整页首个日期、误把
// 页面头部"今天是2026年9月13日"当作发布日的问题):
// 1. 优先 <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 {
title = stripTags(m[1])
title = cleanText(m[1])
}
if title == "" {
if strings.TrimSpace(title) == "" {
if m := h1Re.FindStringSubmatch(html); len(m) > 1 {
title = stripTags(m[1])
title = cleanText(m[1])
}
}
title = strings.TrimSpace(title)
body := scriptRe.ReplaceAllString(html, " ")
body = styleRe.ReplaceAllString(body, " ")
text := stripTags(body)
@ -353,19 +407,76 @@ func extractDetail(html string) (title, content, publishDate, deadline, examDate
text = string([]rune(text)[:4000])
}
content = text
publishDate = normalizeDate(firstDate(html))
deadline = normalizeDate(findDateAfter(text, "报名"))
examDate = normalizeDate(findDateAfter(text, "笔试"))
publishDate = extractPublishDate(html, text, cfg)
// 截止/笔试日期在正文中按关键词就近查找:
// 政府公告常写「报名时间2026年9月1日9:00至9月5日17:00」
// 故限制关键词后 40 字内取证,避免取到正文别处的无关日期。
deadline = normalizeDate(findDateAfterLimit(text, "报名", 40))
examDate = normalizeDate(findDateAfterLimit(text, "笔试", 40))
return
}
// firstDate 返回文本中首个日期(归一化后)。
func firstDate(s string) string {
m := dateRe.FindStringSubmatch(s)
// extractPublishDate 按优先级分段抽取发布日期,全部未命中返回空串。
func extractPublishDate(html, text string, cfg SourceConfig) string {
// 1. meta 标准字段(最可靠)。
for _, re := range metaDateRes {
if m := re.FindStringSubmatch(html); len(m) > 1 {
if d := normalizeDate(m[1]); d != "" {
return d
}
}
}
// 2. 语义容器内的日期(如 <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 {
return ""
}
return normalizeDate(m[0])
return m[0]
}
// findDateAfter 在 keyword 之后查找下一个日期(用于报名/笔试时间)。
@ -406,6 +517,30 @@ func collapseSpace(s string) string {
return spaceRe.ReplaceAllString(s, " ")
}
// cleanText 清理文本中的标签残留与首尾空白,用于标题等展示字段。
// 政府站的锚点内文本常含换行与制表符(如 "\n\t\t\t人事招考"),需归一化。
func cleanText(s string) string {
return strings.TrimSpace(collapseSpace(stripTags(s)))
}
// isImageFileName 判断文本是否为图片文件名(图片型锚点的特征)。
func isImageFileName(s string) bool {
s = strings.TrimSpace(s)
if s == "" || strings.ContainsAny(s, "\n") {
return false
}
return imageFileRe.MatchString(s)
}
// firstAttr 用正则从 HTML 片段中抽取首个属性值(如 img 的 alt/title
func firstAttr(html string, re *regexp.Regexp) string {
m := re.FindStringSubmatch(html)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(collapseSpace(m[1]))
}
// resolveURL 将相对链接解析为绝对 URL。
func resolveURL(base, href string) string {
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
@ -563,11 +698,20 @@ func nullIfEmpty(s string) any {
return s
}
// recordCrawlLog 写抓取日志并更新数据源运行状态(成功清失败计数;失败累加)。
// autoDisableThreshold 连续失败达该次数后自动禁用数据源,避免无效空跑。
// 采用「连续」语义:中间任意一次成功即归零,避免偶发网络抖动误禁。
const autoDisableThreshold = 5
// autoDisableWarnThreshold 连续失败达该次数时先推送一次预警(尚未禁用)。
const autoDisableWarnThreshold = 3
// recordCrawlLog 写抓取日志并更新数据源运行状态。
// - 成功:清失败计数、更新 LastSuccessAt 与最近运行摘要冗余列;
// - 失败:累加失败计数,达阈值自动禁用并推送告警。
func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
errMsg := ""
if res.Err != nil {
// 错误可能含整页 SQL截断避免超过 error 列长度导致日志也写不进。
// 错误可能含整页内容,截断避免超过 error 列长度导致日志也写不进。
errMsg = res.Err.Error()
const maxErr = 900
if len(errMsg) > maxErr {
@ -580,15 +724,54 @@ func recordCrawlLog(ctx context.Context, res dto.CrawlRunResult) {
}).Insert(); err != nil {
g.Log().Errorf(ctx, "写入抓取日志失败: %v", err)
}
if res.Err != nil {
// 读取当前源状态,用于累计失败次数与判断是否需自动禁用。
var src entity.CrawlSource
_ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Scan(&src)
_, _ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).
Data(do.CrawlSource{FailCount: src.FailCount + 1}).Update()
if err := dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Scan(&src); err != nil {
if !strings.Contains(err.Error(), "no rows") {
g.Log().Errorf(ctx, "查询数据源状态失败: %v", err)
}
return
}
_, _ = dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).
Data(do.CrawlSource{LastSuccessAt: gtime.Now().String(), FailCount: 0}).Update()
// 无论成败都同步「最近一次运行摘要」冗余列,供 Sources() 零 JOIN 读取。
summary := do.CrawlSource{
LastLogAt: gtime.Now(),
LastLogFetched: res.Fetched,
LastLogNew: res.NewCount,
LastLogError: truncateStr(errMsg, 500),
}
if res.Err != nil {
failCount := src.FailCount + 1
summary.FailCount = failCount
// 达阈值自动禁用,防止持续无效空跑并污染日志。
if failCount >= autoDisableThreshold && src.Enabled == 1 {
summary.Enabled = 0
g.Log().Warningf(ctx, "数据源 %d(%s) 连续失败 %d 次,已自动禁用", src.Id, src.Name, failCount)
notifySourceDisabled(ctx, src, failCount, errMsg)
} else if failCount == autoDisableWarnThreshold {
// 首次达预警线时推送一次提醒,便于人工介入。
notifySourceFailing(ctx, src, failCount, errMsg)
}
} else {
summary.LastSuccessAt = gtime.Now().String()
summary.FailCount = 0
}
if _, err := dao.CrawlSource.Ctx(ctx).Where("id", res.SourceId).Data(summary).Update(); err != nil {
g.Log().Errorf(ctx, "更新数据源状态失败: %v", err)
}
}
// truncateStr 按最大字符数截断字符串,超长时添加省略标记。
func truncateStr(s string, n int) string {
if len(s) <= n {
return s
}
if n <= 3 {
return s[:n]
}
return s[:n-3] + "..."
}
// ---------- 订阅辅助 ----------

View 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
}

View 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)
}
}

View 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)
}
}
}

View File

@ -8,7 +8,6 @@ import (
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
@ -197,20 +196,15 @@ func (s *recruitment) Trend(ctx context.Context, region string, category, days i
}
// Sources 数据源列表与最近运行状态。
//
// 性能说明:最近一次运行结果直接读 crawl_source 的 last_log_* 冗余列
// (由 recordCrawlLog 写入),不再全表扫描 crawl_log——后者只增不减
// 原实现会随运行时间线性劣化。
func (s *recruitment) Sources(ctx context.Context) ([]dto.SourceItem, error) {
var srcs []entity.CrawlSource
if err := dao.CrawlSource.Ctx(ctx).OrderAsc("id").Scan(&srcs); err != nil {
return nil, gerror.Wrap(err, "查询抓取数据源失败")
}
// 取每个源最近一条日志作为摘要。
var logs []entity.CrawlLog
_ = dao.CrawlLog.Ctx(ctx).OrderDesc("id").Scan(&logs)
lastBySource := make(map[uint64]entity.CrawlLog)
for _, l := range logs {
if _, ok := lastBySource[l.SourceId]; !ok {
lastBySource[l.SourceId] = l
}
}
out := make([]dto.SourceItem, 0, len(srcs))
for _, src := range srcs {
item := dto.SourceItem{
@ -218,15 +212,14 @@ func (s *recruitment) Sources(ctx context.Context) ([]dto.SourceItem, error) {
Category: src.Category, Region: src.Region, Enabled: src.Enabled,
LastSuccessAt: src.LastSuccessAt, FailCount: src.FailCount,
}
if lg, ok := lastBySource[src.Id]; ok {
sum := "抓取"
if lg.Error != "" {
sum += "失败: " + lg.Error
// 依据冗余列拼装最近一次运行摘要(无需 JOIN crawl_log
if src.LastLogAt != nil {
if src.LastLogError != "" {
item.LastSummary = "抓取失败: " + src.LastLogError
} else {
sum += "成功 " + gtime.New(lg.RunAt).Format("Y-m-d H:i") +
" 新增" + strconv.Itoa(lg.NewCount) + "/抓取" + strconv.Itoa(lg.Fetched)
item.LastSummary = "抓取成功 " + src.LastLogAt.Format("Y-m-d H:i") +
" 新增" + strconv.Itoa(src.LastLogNew) + "/抓取" + strconv.Itoa(src.LastLogFetched)
}
item.LastSummary = sum
}
out = append(out, item)
}

View 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 单次抓取详情页上限,默认 60force 模式下放宽到 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]
}

File diff suppressed because it is too large Load Diff

View 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;
-- status0待发送 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=1result=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);

View 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;