这两批改动此前一直压在工作区未提交,本次一并归档。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,
生产库需手动导入。
224 lines
7.3 KiB
Go
224 lines
7.3 KiB
Go
package recruitment
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/gogf/gf/v2/frame/g"
|
||
"github.com/gogf/gf/v2/os/gtime"
|
||
|
||
"service.xpcool.com/internal/dao"
|
||
"service.xpcool.com/internal/model/do"
|
||
"service.xpcool.com/internal/model/dto"
|
||
"service.xpcool.com/internal/model/entity"
|
||
)
|
||
|
||
// barkPayload 对应自建 Bark 服务的 /push 接口入参。
|
||
type barkPayload struct {
|
||
DeviceKey string `json:"device_key"`
|
||
Title string `json:"title"`
|
||
Body string `json:"body"`
|
||
Group string `json:"group"` // 通知分组
|
||
Level string `json:"level"` // 优先级
|
||
URL string `json:"url"` // 点击跳转
|
||
}
|
||
|
||
// defaultDeviceKey 读取环境变量注入的兜底设备密钥(BARK_DEVICE_KEY -> bark.deviceKey)。
|
||
// 当数据库没有任何订阅记录时,推送与测试推送回退到该密钥,做到「填了 env 即可推送」。
|
||
func defaultDeviceKey(ctx context.Context) string {
|
||
return g.Cfg().MustGet(ctx, "bark.deviceKey", "").String()
|
||
}
|
||
|
||
// defaultSubscription 构造一个使用兜底密钥的默认订阅(全地区/全分类/仅新公告)。
|
||
func defaultSubscription(ctx context.Context) *entity.PushSubscription {
|
||
key := defaultDeviceKey(ctx)
|
||
if key == "" {
|
||
return nil
|
||
}
|
||
return &entity.PushSubscription{
|
||
Id: 0, Name: "默认(Bark环境变量)", DeviceKey: key,
|
||
Regions: "[]", Categories: "[]", OnlyNew: 1, PushTime: "08:00", Enabled: 1,
|
||
}
|
||
}
|
||
|
||
// barkPush 向指定设备密钥发送 Bark 推送(自建服务:POST {baseUrl}/push)。
|
||
func barkPush(ctx context.Context, deviceKey, title, body string) (bool, string, error) {
|
||
baseURL := g.Cfg().MustGet(ctx, "bark.baseUrl", "").String()
|
||
if baseURL == "" {
|
||
return false, "未配置 bark.baseUrl", fmt.Errorf("bark baseUrl empty")
|
||
}
|
||
if deviceKey == "" {
|
||
// 未显式传入则回退到环境变量兜底密钥。
|
||
deviceKey = defaultDeviceKey(ctx)
|
||
}
|
||
if deviceKey == "" {
|
||
return false, "设备密钥为空", fmt.Errorf("device key empty")
|
||
}
|
||
payload := barkPayload{
|
||
DeviceKey: deviceKey,
|
||
Title: title,
|
||
Body: body,
|
||
Group: "recruit_daily",
|
||
Level: "active",
|
||
}
|
||
client := g.Client()
|
||
client.SetTimeout(10 * time.Second)
|
||
resp, err := client.Post(ctx, strings.TrimRight(baseURL, "/")+"/push", payload)
|
||
if err != nil {
|
||
return false, err.Error(), err
|
||
}
|
||
defer resp.Close()
|
||
var out struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
}
|
||
_ = json.Unmarshal(resp.ReadAll(), &out)
|
||
ok := resp.StatusCode == 200 && out.Code == 200
|
||
msg := out.Message
|
||
if msg == "" {
|
||
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||
}
|
||
return ok, msg, nil
|
||
}
|
||
|
||
// sendDailyDigest 对所有启用订阅发送「招聘早报」汇总(自建 Bark)。
|
||
// 若库内无任何订阅,则回退到环境变量兜底密钥(BARK_DEVICE_KEY)发一份全量早报。
|
||
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, "加载推送订阅失败: %v", err)
|
||
return
|
||
}
|
||
if len(subs) == 0 {
|
||
def := defaultSubscription(ctx)
|
||
if def == nil {
|
||
g.Log().Infof(ctx, "无启用订阅且未配置兜底设备密钥,跳过早报推送")
|
||
return
|
||
}
|
||
subs = []entity.PushSubscription{*def}
|
||
}
|
||
for _, sub := range subs {
|
||
title, body := buildDigest(ctx, sub)
|
||
if title == "" && body == "" {
|
||
// 仅推送新公告且今日无新:跳过,避免打扰。
|
||
continue
|
||
}
|
||
ok, msg, _ := barkPush(ctx, sub.DeviceKey, title, body)
|
||
recordPushLog(ctx, sub.Id, title, body, ok, msg)
|
||
}
|
||
}
|
||
|
||
// buildDigest 按订阅过滤条件生成早报内容;仅推送新公告且无新时返回空串。
|
||
func buildDigest(ctx context.Context, sub entity.PushSubscription) (string, string) {
|
||
regions := parseJSONStrings(sub.Regions)
|
||
cats := parseJSONInts(sub.Categories)
|
||
since := gtime.Now().StartOfDay().String()
|
||
m := dao.RecruitmentInfo.Ctx(ctx).Where("status", g.Slice{0, 1}).Where("created_at >= ?", since)
|
||
if len(regions) > 0 {
|
||
m = m.Where("region IN (?)", regions)
|
||
}
|
||
if len(cats) > 0 {
|
||
m = m.Where("category IN (?)", cats)
|
||
}
|
||
var list []dto.RecruitmentInfoVO
|
||
if err := m.Fields("title, category, region, deadline").OrderDesc("publish_date").Scan(&list); err != nil {
|
||
g.Log().Errorf(ctx, "早报内容查询失败: %v", err)
|
||
return "", ""
|
||
}
|
||
if sub.OnlyNew == 1 && len(list) == 0 {
|
||
return "", "" // 无新公告则跳过
|
||
}
|
||
counts := map[int]int{}
|
||
for _, v := range list {
|
||
counts[v.Category]++
|
||
}
|
||
var sb strings.Builder
|
||
sb.WriteString("今日新增:")
|
||
parts := make([]string, 0, len(counts))
|
||
for c, n := range counts {
|
||
parts = append(parts, dto.CategoryName(c)+" "+itoa(n))
|
||
}
|
||
sb.WriteString(strings.Join(parts, " · "))
|
||
sb.WriteString("\n")
|
||
limit := 8
|
||
for i, v := range list {
|
||
if i >= limit {
|
||
break
|
||
}
|
||
line := "· " + v.Title
|
||
if v.Deadline != "" {
|
||
line += "(截止 " + v.Deadline + ")"
|
||
}
|
||
sb.WriteString(line + "\n")
|
||
}
|
||
title := "贵州招聘早报 · " + gtime.Now().Format("Y-m-d")
|
||
if len(regions) == 1 {
|
||
title += " · " + regions[0]
|
||
}
|
||
return title, strings.TrimRight(sb.String(), "\n")
|
||
}
|
||
|
||
// recordPushLog 写推送记录。
|
||
func recordPushLog(ctx context.Context, subId uint64, title, body string, ok bool, msg string) {
|
||
res := 0
|
||
if ok {
|
||
res = 1
|
||
}
|
||
if _, err := dao.PushLog.Ctx(ctx).Data(do.PushLog{
|
||
SubscriptionId: subId, PushAt: gtime.Now(), Title: title, Body: body,
|
||
Result: res, Error: msg, CreatedAt: gtime.Now(),
|
||
}).Insert(); err != nil {
|
||
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)
|
||
}
|
||
}
|
||
}
|