// Package recruitment 提供招聘考试聚合领域服务(业务/爬虫/调度/推送)。 package recruitment import ( "context" "strconv" "strings" "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" "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 数据源列表与最近运行状态。 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{ 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, } if lg, ok := lastBySource[src.Id]; ok { sum := "抓取" if lg.Error != "" { sum += "失败: " + lg.Error } else { sum += "成功 " + gtime.New(lg.RunAt).Format("Y-m-d H:i") + " 新增" + strconv.Itoa(lg.NewCount) + "/抓取" + strconv.Itoa(lg.Fetched) } item.LastSummary = sum } 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) }