这两批改动此前一直压在工作区未提交,本次一并归档。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,
生产库需手动导入。
320 lines
11 KiB
Go
320 lines
11 KiB
Go
// 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(®ionRows); 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)
|
||
}
|