service.xpcool.com/internal/service/recruitment/recruitment.go
夏犀麟 4445b8e8bf
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 14s
CHG: house transaction service 层 + recruitment 招聘模块 + 012 菜单 SQL(补提交未推送代码)
2026-08-27 00:41:59 +08:00

323 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"
"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 implementation not registered")
}
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, "count recruitment")
}
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, "query recruitment list")
}
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, "query recruitment detail")
}
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, "count total")
}
todayNew, err := base.Clone().Where("DATE(created_at)=CURDATE()").Count()
if err != nil {
return nil, gerror.Wrap(err, "count today")
}
weekNew, err := base.Clone().Where("created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)").Count()
if err != nil {
return nil, gerror.Wrap(err, "count week")
}
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, "agg category")
}
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, "agg region")
}
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, "trend")
}
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, "query trend")
}
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, "query sources")
}
// 取每个源最近一条日志作为摘要。
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, "query crawl sources")
}
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 {
return false, "无启用的推送订阅", gerror.New("no enabled subscription")
}
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, "query subscriptions")
}
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, "update subscription")
}
return in.Id, nil
}
id, err := dao.PushSubscription.Ctx(ctx).Data(data).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert subscription")
}
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, "delete subscription")
}
return nil
}
// itoa 整型转字符串(摘要拼接用)。
func itoa(v int) string {
return strconv.Itoa(v)
}