service.xpcool.com/internal/service/recruitment/recruitment.go
夏犀麟 ea50261cb8
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 35s
feat(house): 扩充看房数据并增强服务器日志功能
- 新增新房预售证表 house_presale 存储预售许可信息
- 为 house_community 表添加 avg_price 字段存储小区参考均价
- 增强服务器操作审计日志功能,新增管理员账号、IP归属地、错误信息、UA等字段
- 实现纯Go版IP归属地离线解析器,无外部依赖,支持二分查找
- 优化看房列表查询逻辑,修复Fields设置位置导致的SQL语法错误
- 集成招聘模块,添加独立数据库配置和Bark推送服务支持
- 重构日志查询接口,支持多维度筛选和综合分页列表展示
- 更新DAO实体结构同步数据库表结构调整
2026-08-27 01:47:32 +08:00

327 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 {
// 未指定/未找到订阅时回退到环境变量兜底密钥BARK_DEVICE_KEY
sub = defaultSubscription(ctx)
}
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)
}