service.xpcool.com/internal/service/recruitment/recruitment.go
夏犀麟 4b3c00270f
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 31s
feat(i18n): 中文化校验提示与服务层错误信息
将 api 层校验规则提示语、service 层 gerror.Wrap 与 response.Error
错误信息、panic 未注册提示统一改为中文,并同步中文化 cmd 路由注释
与变更记录。仅涉及注释、文档与字符串改动,无业务逻辑变更。
2026-09-13 23:22:46 +08:00

327 lines
11 KiB
Go
Raw Permalink 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 实现未注册")
}
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 数据源列表与最近运行状态。
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)
}