Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 14s
323 lines
11 KiB
Go
323 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"
|
||
"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(®ionRows); 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)
|
||
}
|