service.xpcool.com/internal/service/recruitment/bark.go
夏犀麟 ad4567fc01 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,
生产库需手动导入。
2026-09-14 01:22:39 +08:00

224 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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