service.xpcool.com/internal/service/recruitment/recruitment.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

320 lines
11 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 提供招聘考试聚合领域服务(业务/爬虫/调度/推送)。
package recruitment
import (
"context"
"strconv"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/library/response"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/model/entity"
)
// IRecruitment 招聘考试聚合领域服务接口。
type IRecruitment interface {
List(context.Context, dto.RecruitmentFilter) ([]dto.RecruitmentInfoVO, int, error)
Detail(context.Context, uint64) (*dto.RecruitmentInfoVO, error)
Stats(context.Context, string) (*dto.RecruitmentStats, error)
Trend(context.Context, string, int, int) ([]dto.TrendPoint, error)
Sources(context.Context) ([]dto.SourceItem, error)
Trigger(context.Context, uint64, bool) (int, string, error)
PushTest(context.Context, uint64, string, string) (bool, string, error)
SubscriptionList(context.Context) ([]dto.SubscriptionItem, error)
SubscriptionSave(context.Context, dto.SubscriptionInput) (uint64, error)
SubscriptionDelete(context.Context, uint64) error
}
type recruitment struct{}
var localRecruitment IRecruitment
// New 创建招聘领域服务实现。
func New() IRecruitment { return &recruitment{} }
// Recruitment 返回已注册的招聘服务实现。
func Recruitment() IRecruitment {
if localRecruitment == nil {
panic("Recruitment 实现未注册")
}
return localRecruitment
}
// RegisterRecruitment 注册招聘服务实现。
func RegisterRecruitment(i IRecruitment) { localRecruitment = i }
// List 分页查询公告,关联数据源名称,支持多维筛选(默认只看未失效)。
func (s *recruitment) List(ctx context.Context, f dto.RecruitmentFilter) ([]dto.RecruitmentInfoVO, int, error) {
m := dao.RecruitmentInfo.Ctx(ctx).As("r").LeftJoin("crawl_source cs", "r.source_id=cs.id")
m = m.Fields("r.*, cs.name AS source_name")
if f.Region != "" {
m = m.Where("r.region", f.Region)
}
if f.Category > 0 {
m = m.Where("r.category", f.Category)
}
if f.Keyword != "" {
kw := "%" + f.Keyword + "%"
m = m.Where("r.title LIKE ? OR r.content LIKE ? OR r.org_name LIKE ?", kw, kw, kw)
}
if f.Status > 0 {
m = m.Where("r.status", f.Status)
} else {
// 默认排除「已失效/已删除」,保留有效与已更正。
m = m.Where("r.status", g.Slice{0, 1})
}
if f.DateFrom != "" {
m = m.WhereGTE("r.publish_date", f.DateFrom)
}
if f.DateTo != "" {
m = m.WhereLTE("r.publish_date", f.DateTo)
}
if f.OrgName != "" {
m = m.Where("r.org_name LIKE ?", "%"+f.OrgName+"%")
}
if f.SourceId > 0 {
m = m.Where("r.source_id", f.SourceId)
}
if f.OnlyNewToday {
m = m.Where("DATE(r.created_at)=CURDATE()")
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "统计招聘公告总数失败")
}
var list []dto.RecruitmentInfoVO
if err = m.Clone().Page(f.Page, f.Size).OrderDesc("r.publish_date").OrderDesc("r.id").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "查询招聘公告列表失败")
}
for i := range list {
list[i].CategoryName = dto.CategoryName(list[i].Category)
list[i].StatusName = dto.StatusName(list[i].Status)
}
return list, total, nil
}
// Detail 公告详情(带数据源名称)。
func (s *recruitment) Detail(ctx context.Context, id uint64) (*dto.RecruitmentInfoVO, error) {
var v dto.RecruitmentInfoVO
err := dao.RecruitmentInfo.Ctx(ctx).
As("r").LeftJoin("crawl_source cs", "r.source_id=cs.id").
Fields("r.*, cs.name AS source_name").
Where("r.id", id).Scan(&v)
if err != nil {
return nil, gerror.Wrap(err, "查询招聘公告详情失败")
}
if v.Id == 0 {
return nil, response.Error(consts.CodeInvalidParam, "公告不存在")
}
v.CategoryName = dto.CategoryName(v.Category)
v.StatusName = dto.StatusName(v.Status)
return &v, nil
}
// Stats 看板统计:总数/今日/近7天/分类分布/地区分布/近30天趋势。
func (s *recruitment) Stats(ctx context.Context, region string) (*dto.RecruitmentStats, error) {
base := dao.RecruitmentInfo.Ctx(ctx).Where("status", g.Slice{0, 1})
if region != "" {
base = base.Where("region", region)
}
total, err := base.Clone().Count()
if err != nil {
return nil, gerror.Wrap(err, "统计公告总数失败")
}
todayNew, err := base.Clone().Where("DATE(created_at)=CURDATE()").Count()
if err != nil {
return nil, gerror.Wrap(err, "统计今日新增失败")
}
weekNew, err := base.Clone().Where("created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)").Count()
if err != nil {
return nil, gerror.Wrap(err, "统计近 7 天新增失败")
}
var catRows []struct {
Category int `json:"category"`
Cnt int `json:"cnt"`
}
if err = base.Clone().Fields("category, COUNT(*) AS cnt").Group("category").Scan(&catRows); err != nil {
return nil, gerror.Wrap(err, "按分类聚合失败")
}
var regionRows []struct {
Region string `json:"region"`
Cnt int `json:"cnt"`
}
if err = base.Clone().Fields("region, COUNT(*) AS cnt").Group("region").Scan(&regionRows); err != nil {
return nil, gerror.Wrap(err, "按地区聚合失败")
}
var trendRows []struct {
D string `json:"d"`
Cnt int `json:"cnt"`
}
if err = base.Clone().
Fields("DATE(publish_date) AS d, COUNT(*) AS cnt").
Where("publish_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)").
Group("d").OrderAsc("d").Scan(&trendRows); err != nil {
return nil, gerror.Wrap(err, "统计近 30 天趋势失败")
}
out := &dto.RecruitmentStats{Total: int(total), TodayNew: int(todayNew), WeekNew: int(weekNew)}
for _, r := range catRows {
out.ByCategory = append(out.ByCategory, dto.CategoryAgg{Category: r.Category, CategoryName: dto.CategoryName(r.Category), Count: r.Cnt})
}
for _, r := range regionRows {
out.ByRegion = append(out.ByRegion, dto.RegionAgg{Region: r.Region, Count: r.Cnt})
}
for _, r := range trendRows {
out.RecentTrend = append(out.RecentTrend, dto.TrendPoint{Date: r.D, Count: r.Cnt})
}
return out, nil
}
// Trend 按日趋势(可选地区/分类/天数)。
func (s *recruitment) Trend(ctx context.Context, region string, category, days int) ([]dto.TrendPoint, error) {
m := dao.RecruitmentInfo.Ctx(ctx).Where("status", g.Slice{0, 1})
if region != "" {
m = m.Where("region", region)
}
if category > 0 {
m = m.Where("category", category)
}
m = m.Where("publish_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)", days)
var rows []struct {
D string `json:"d"`
Cnt int `json:"cnt"`
}
if err := m.Fields("DATE(publish_date) AS d, COUNT(*) AS cnt").Group("d").OrderAsc("d").Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "查询趋势数据失败")
}
out := make([]dto.TrendPoint, 0, len(rows))
for _, r := range rows {
out = append(out, dto.TrendPoint{Date: r.D, Count: r.Cnt})
}
return out, nil
}
// 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, "查询抓取数据源失败")
}
out := make([]dto.SourceItem, 0, len(srcs))
for _, src := range srcs {
item := dto.SourceItem{
Id: src.Id, Name: src.Name, BaseUrl: src.BaseUrl, SourceType: src.SourceType,
Category: src.Category, Region: src.Region, Enabled: src.Enabled,
LastSuccessAt: src.LastSuccessAt, FailCount: src.FailCount,
}
// 依据冗余列拼装最近一次运行摘要(无需 JOIN crawl_log
if src.LastLogAt != nil {
if src.LastLogError != "" {
item.LastSummary = "抓取失败: " + src.LastLogError
} else {
item.LastSummary = "抓取成功 " + src.LastLogAt.Format("Y-m-d H:i") +
" 新增" + strconv.Itoa(src.LastLogNew) + "/抓取" + strconv.Itoa(src.LastLogFetched)
}
}
out = append(out, item)
}
return out, nil
}
// Trigger 手动触发抓取sourceId=0 表示全部启用源force=true 强制全量回溯。
func (s *recruitment) Trigger(ctx context.Context, sourceId uint64, force bool) (int, string, error) {
m := dao.CrawlSource.Ctx(ctx)
if sourceId > 0 {
m = m.Where("id", sourceId)
} else {
m = m.Where("enabled", 1)
}
var srcs []entity.CrawlSource
if err := m.OrderAsc("id").Scan(&srcs); err != nil {
return 0, "", gerror.Wrap(err, "查询待抓取数据源失败")
}
var sb strings.Builder
n := 0
for _, src := range srcs {
res := crawlerRun(ctx, src, force)
recordCrawlLog(ctx, res)
n++
sb.WriteString(src.Name + ": +" + itoa(res.NewCount) + "/~" + itoa(res.Fetched) + "; ")
}
return n, sb.String(), nil
}
// PushTest 向指定订阅(或首个启用订阅)发送一条 Bark 测试推送。
func (s *recruitment) PushTest(ctx context.Context, subId uint64, title, body string) (bool, string, error) {
sub, err := loadSubscription(ctx, subId)
if err != nil {
return false, err.Error(), err
}
if sub == nil {
// 未指定/未找到订阅时回退到环境变量兜底密钥BARK_DEVICE_KEY
sub = defaultSubscription(ctx)
}
if sub == nil {
return false, "无启用的推送订阅且未配置默认设备密钥", gerror.New("没有启用的推送订阅")
}
if title == "" {
title = "招聘聚合 · 推送测试"
}
if body == "" {
body = "这是一条来自 service.xpcool.com 的测试推送。"
}
return barkPush(ctx, sub.DeviceKey, title, body)
}
// SubscriptionList 推送订阅列表。
func (s *recruitment) SubscriptionList(ctx context.Context) ([]dto.SubscriptionItem, error) {
var subs []entity.PushSubscription
if err := dao.PushSubscription.Ctx(ctx).OrderDesc("id").Scan(&subs); err != nil {
return nil, gerror.Wrap(err, "查询推送订阅失败")
}
out := make([]dto.SubscriptionItem, 0, len(subs))
for _, sub := range subs {
out = append(out, dto.SubscriptionItem{
Id: sub.Id, Name: sub.Name, DeviceKey: sub.DeviceKey,
Regions: parseJSONStrings(sub.Regions), Categories: parseJSONInts(sub.Categories),
OnlyNew: sub.OnlyNew, PushTime: sub.PushTime, Enabled: sub.Enabled,
})
}
return out, nil
}
// SubscriptionSave 新增/更新推送订阅。
func (s *recruitment) SubscriptionSave(ctx context.Context, in dto.SubscriptionInput) (uint64, error) {
data := doPushSubscription(in)
if in.Id > 0 {
_, err := dao.PushSubscription.Ctx(ctx).Where("id", in.Id).Data(data).Update()
if err != nil {
return 0, gerror.Wrap(err, "更新推送订阅失败")
}
return in.Id, nil
}
id, err := dao.PushSubscription.Ctx(ctx).Data(data).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "新增推送订阅失败")
}
return uint64(id), nil
}
// SubscriptionDelete 删除推送订阅。
func (s *recruitment) SubscriptionDelete(ctx context.Context, id uint64) error {
if _, err := dao.PushSubscription.Ctx(ctx).Where("id", id).Delete(); err != nil {
return gerror.Wrap(err, "删除推送订阅失败")
}
return nil
}
// itoa 整型转字符串(摘要拼接用)。
func itoa(v int) string {
return strconv.Itoa(v)
}