CHG: house transaction service 层 + recruitment 招聘模块 + 012 菜单 SQL(补提交未推送代码)
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 14s

This commit is contained in:
夏犀麟 2026-08-27 00:41:59 +08:00
parent a2f60c3410
commit 4445b8e8bf
10 changed files with 1164 additions and 0 deletions

View File

@ -0,0 +1,191 @@
// Package recruitment_v1 招聘考试聚合模块接口契约。
// 规范2026-08-27全部 POSTURL 不含任何参数(查询/路径参数均禁止);入参一律走 body。
package recruitment_v1
import "github.com/gogf/gf/v2/frame/g"
// ---------- 公告列表 ----------
type RecruitmentListReq struct {
g.Meta `path:"/recruitment/info/list" method:"post" tags:"Admin/Recruitment/Info" summary:"招聘公告列表(多维筛选)"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
Region string `json:"region"` // 地区过滤(空=全部)
Category int `json:"category"` // 分类(0=全部)
Keyword string `json:"keyword"` // 标题/正文/单位关键字
Status int `json:"status"` // 状态(0=有效+已更正;传 2 只看已失效等)
DateFrom string `json:"dateFrom"` // 发布日期起点 YYYY-MM-DD
DateTo string `json:"dateTo"` // 发布日期终点 YYYY-MM-DD
OrgName string `json:"orgName"` // 发布主体名称模糊匹配
SourceId uint64 `json:"sourceId"` // 指定数据源
OnlyNewToday bool `json:"onlyNewToday"` // 仅当日新增
}
type RecruitmentItem struct {
Id uint64 `json:"id"`
Title string `json:"title"`
SourceId uint64 `json:"sourceId"`
SourceName string `json:"sourceName"`
OrgId uint64 `json:"orgId"`
OrgName string `json:"orgName"`
Category int `json:"category"`
CategoryName string `json:"categoryName"`
Region string `json:"region"`
PublishDate string `json:"publishDate"`
Deadline string `json:"deadline"`
ExamDate string `json:"examDate"`
Url string `json:"url"`
Content string `json:"content"`
Attachments string `json:"attachments"`
Status int `json:"status"`
StatusName string `json:"statusName"`
GroupKey string `json:"groupKey"`
CreatedAt string `json:"createdAt"`
}
type RecruitmentListRes struct {
List []*RecruitmentItem `json:"list"`
Total int `json:"total"`
}
// ---------- 公告详情 ----------
type RecruitmentDetailReq struct {
g.Meta `path:"/recruitment/info/detail" method:"post" tags:"Admin/Recruitment/Info" summary:"招聘公告详情"`
Id uint64 `json:"id" v:"required"`
}
type RecruitmentDetailRes struct {
Info *RecruitmentItem `json:"info"`
}
// ---------- 看板统计 ----------
type RecruitmentStatsReq struct {
g.Meta `path:"/recruitment/stats" method:"post" tags:"Admin/Recruitment/Dashboard" summary:"招聘数据看板统计"`
Region string `json:"region"` // 可选:按地区过滤统计
}
type RecruitmentStatsRes struct {
Total int `json:"total"`
TodayNew int `json:"todayNew"`
WeekNew int `json:"weekNew"`
ByCategory []CategoryAggItem `json:"byCategory"`
ByRegion []RegionAggItem `json:"byRegion"`
RecentTrend []TrendPointItem `json:"recentTrend"`
}
type CategoryAggItem struct {
Category int `json:"category"`
CategoryName string `json:"categoryName"`
Count int `json:"count"`
}
type RegionAggItem struct {
Region string `json:"region"`
Count int `json:"count"`
}
type TrendPointItem struct {
Date string `json:"date"`
Count int `json:"count"`
}
// ---------- 趋势分析 ----------
type RecruitmentTrendReq struct {
g.Meta `path:"/recruitment/trend" method:"post" tags:"Admin/Recruitment/Dashboard" summary:"招聘公告趋势(按日)"`
Region string `json:"region"`
Category int `json:"category"`
Days int `json:"days" d:"30" v:"min:1|max:365"`
}
type RecruitmentTrendRes struct {
Trend []TrendPointItem `json:"trend"`
}
// ---------- 数据源状态 ----------
type RecruitmentSourcesReq struct {
g.Meta `path:"/recruitment/source/list" method:"post" tags:"Admin/Recruitment/Crawler" summary:"数据源列表与运行状态"`
}
type SourceItem struct {
Id uint64 `json:"id"`
Name string `json:"name"`
BaseUrl string `json:"baseUrl"`
SourceType int `json:"sourceType"`
Category int `json:"category"`
Region string `json:"region"`
Enabled int `json:"enabled"`
LastSuccessAt string `json:"lastSuccessAt"`
FailCount int `json:"failCount"`
LastSummary string `json:"lastSummary"`
}
type RecruitmentSourcesRes struct {
List []*SourceItem `json:"list"`
}
// ---------- 手动触发抓取 ----------
type RecruitmentTriggerReq struct {
g.Meta `path:"/recruitment/crawl/trigger" method:"post" tags:"Admin/Recruitment/Crawler" summary:"手动触发抓取(单源或全量)"`
SourceId uint64 `json:"sourceId"` // 0=全部启用源
Force bool `json:"force"` // 是否强制全量回溯
}
type RecruitmentTriggerRes struct {
Triggered int `json:"triggered"` // 触发的源数量
Summary string `json:"summary"` // 运行摘要
}
// ---------- Bark 推送测试 ----------
type RecruitmentPushTestReq struct {
g.Meta `path:"/recruitment/push/test" method:"post" tags:"Admin/Recruitment/Push" summary:"Bark 推送测试"`
SubscriptionId uint64 `json:"subscriptionId"` // 0=使用首个启用订阅
Title string `json:"title"`
Body string `json:"body"`
}
type RecruitmentPushTestRes struct {
Result bool `json:"result"`
Message string `json:"message"`
}
// ---------- 推送订阅管理 ----------
type RecruitmentSubscriptionListReq struct {
g.Meta `path:"/recruitment/subscription/list" method:"post" tags:"Admin/Recruitment/Push" summary:"推送订阅列表"`
}
type SubscriptionItem struct {
Id uint64 `json:"id"`
Name string `json:"name"`
DeviceKey string `json:"deviceKey"`
Regions []string `json:"regions"`
Categories []int `json:"categories"`
OnlyNew int `json:"onlyNew"`
PushTime string `json:"pushTime"`
Enabled int `json:"enabled"`
}
type RecruitmentSubscriptionListRes struct {
List []*SubscriptionItem `json:"list"`
}
type RecruitmentSubscriptionSaveReq struct {
g.Meta `path:"/recruitment/subscription/save" method:"post" tags:"Admin/Recruitment/Push" summary:"保存推送订阅(新增/更新)"`
Id uint64 `json:"id"` // 0=新增
Name string `json:"name" v:"required"`
DeviceKey string `json:"deviceKey" v:"required"`
Regions []string `json:"regions"`
Categories []int `json:"categories"`
OnlyNew int `json:"onlyNew" d:"1"`
PushTime string `json:"pushTime" d:"08:00"`
Enabled int `json:"enabled" d:"1"`
}
type RecruitmentSubscriptionSaveRes struct {
Id uint64 `json:"id"`
}
type RecruitmentSubscriptionDeleteReq struct {
g.Meta `path:"/recruitment/subscription/delete" method:"post" tags:"Admin/Recruitment/Push" summary:"删除推送订阅"`
Id uint64 `json:"id" v:"required"`
}
type RecruitmentSubscriptionDeleteRes struct{}

View File

@ -0,0 +1,8 @@
// Package recruitment 实现招聘考试聚合模块管理端点,绑定在 admin 受权限保护分组下。
package recruitment
// Controller 实现招聘考试聚合模块的所有端点。
type Controller struct{}
// New 创建招聘模块控制器。
func New() *Controller { return &Controller{} }

View File

@ -0,0 +1,159 @@
package recruitment
import (
"context"
recruitmentv1 "service.xpcool.com/api/recruitment/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service/recruitment"
)
// toItem 将服务层 VO 映射为 API 出参项。
func toItem(v *dto.RecruitmentInfoVO) *recruitmentv1.RecruitmentItem {
if v == nil {
return nil
}
return &recruitmentv1.RecruitmentItem{
Id: v.Id, Title: v.Title, SourceId: v.SourceId, SourceName: v.SourceName,
OrgId: v.OrgId, OrgName: v.OrgName, Category: v.Category, CategoryName: v.CategoryName,
Region: v.Region, PublishDate: v.PublishDate, Deadline: v.Deadline, ExamDate: v.ExamDate,
Url: v.Url, Content: v.Content, Attachments: v.Attachments, Status: v.Status,
StatusName: v.StatusName, GroupKey: v.GroupKey, CreatedAt: v.CreatedAt,
}
}
// InfoList 招聘公告列表(多维筛选)。
func (c *Controller) InfoList(ctx context.Context, req *recruitmentv1.RecruitmentListReq) (res *recruitmentv1.RecruitmentListRes, err error) {
list, total, err := recruitment.Recruitment().List(ctx, dto.RecruitmentFilter{
Page: req.Page, Size: req.Size, Region: req.Region, Category: req.Category,
Keyword: req.Keyword, Status: req.Status, DateFrom: req.DateFrom, DateTo: req.DateTo,
OrgName: req.OrgName, SourceId: req.SourceId, OnlyNewToday: req.OnlyNewToday,
})
if err != nil {
return nil, err
}
out := make([]*recruitmentv1.RecruitmentItem, 0, len(list))
for i := range list {
out = append(out, toItem(&list[i]))
}
return &recruitmentv1.RecruitmentListRes{List: out, Total: total}, nil
}
// InfoDetail 招聘公告详情。
func (c *Controller) InfoDetail(ctx context.Context, req *recruitmentv1.RecruitmentDetailReq) (res *recruitmentv1.RecruitmentDetailRes, err error) {
info, err := recruitment.Recruitment().Detail(ctx, req.Id)
if err != nil {
return nil, err
}
return &recruitmentv1.RecruitmentDetailRes{Info: toItem(info)}, nil
}
// Stats 招聘数据看板统计。
func (c *Controller) Stats(ctx context.Context, req *recruitmentv1.RecruitmentStatsReq) (res *recruitmentv1.RecruitmentStatsRes, err error) {
s, err := recruitment.Recruitment().Stats(ctx, req.Region)
if err != nil {
return nil, err
}
byCat := make([]recruitmentv1.CategoryAggItem, 0, len(s.ByCategory))
for _, v := range s.ByCategory {
byCat = append(byCat, recruitmentv1.CategoryAggItem{Category: v.Category, CategoryName: v.CategoryName, Count: v.Count})
}
byRegion := make([]recruitmentv1.RegionAggItem, 0, len(s.ByRegion))
for _, v := range s.ByRegion {
byRegion = append(byRegion, recruitmentv1.RegionAggItem{Region: v.Region, Count: v.Count})
}
trend := make([]recruitmentv1.TrendPointItem, 0, len(s.RecentTrend))
for _, v := range s.RecentTrend {
trend = append(trend, recruitmentv1.TrendPointItem{Date: v.Date, Count: v.Count})
}
return &recruitmentv1.RecruitmentStatsRes{
Total: s.Total, TodayNew: s.TodayNew, WeekNew: s.WeekNew,
ByCategory: byCat, ByRegion: byRegion, RecentTrend: trend,
}, nil
}
// Trend 招聘公告趋势(按日)。
func (c *Controller) Trend(ctx context.Context, req *recruitmentv1.RecruitmentTrendReq) (res *recruitmentv1.RecruitmentTrendRes, err error) {
pts, err := recruitment.Recruitment().Trend(ctx, req.Region, req.Category, req.Days)
if err != nil {
return nil, err
}
out := make([]recruitmentv1.TrendPointItem, 0, len(pts))
for _, v := range pts {
out = append(out, recruitmentv1.TrendPointItem{Date: v.Date, Count: v.Count})
}
return &recruitmentv1.RecruitmentTrendRes{Trend: out}, nil
}
// SourceList 数据源列表与运行状态。
func (c *Controller) SourceList(ctx context.Context, req *recruitmentv1.RecruitmentSourcesReq) (res *recruitmentv1.RecruitmentSourcesRes, err error) {
list, err := recruitment.Recruitment().Sources(ctx)
if err != nil {
return nil, err
}
out := make([]*recruitmentv1.SourceItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &recruitmentv1.SourceItem{
Id: v.Id, Name: v.Name, BaseUrl: v.BaseUrl, SourceType: v.SourceType,
Category: v.Category, Region: v.Region, Enabled: v.Enabled,
LastSuccessAt: v.LastSuccessAt, FailCount: v.FailCount, LastSummary: v.LastSummary,
})
}
return &recruitmentv1.RecruitmentSourcesRes{List: out}, nil
}
// CrawlTrigger 手动触发抓取(单源或全量)。
func (c *Controller) CrawlTrigger(ctx context.Context, req *recruitmentv1.RecruitmentTriggerReq) (res *recruitmentv1.RecruitmentTriggerRes, err error) {
n, summary, err := recruitment.Recruitment().Trigger(ctx, req.SourceId, req.Force)
if err != nil {
return nil, err
}
return &recruitmentv1.RecruitmentTriggerRes{Triggered: n, Summary: summary}, nil
}
// PushTest Bark 推送测试。
func (c *Controller) PushTest(ctx context.Context, req *recruitmentv1.RecruitmentPushTestReq) (res *recruitmentv1.RecruitmentPushTestRes, err error) {
ok, msg, err := recruitment.Recruitment().PushTest(ctx, req.SubscriptionId, req.Title, req.Body)
if err != nil {
return nil, err
}
return &recruitmentv1.RecruitmentPushTestRes{Result: ok, Message: msg}, nil
}
// SubscriptionList 推送订阅列表。
func (c *Controller) SubscriptionList(ctx context.Context, req *recruitmentv1.RecruitmentSubscriptionListReq) (res *recruitmentv1.RecruitmentSubscriptionListRes, err error) {
list, err := recruitment.Recruitment().SubscriptionList(ctx)
if err != nil {
return nil, err
}
out := make([]*recruitmentv1.SubscriptionItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &recruitmentv1.SubscriptionItem{
Id: v.Id, Name: v.Name, DeviceKey: v.DeviceKey, Regions: v.Regions,
Categories: v.Categories, OnlyNew: v.OnlyNew, PushTime: v.PushTime, Enabled: v.Enabled,
})
}
return &recruitmentv1.RecruitmentSubscriptionListRes{List: out}, nil
}
// SubscriptionSave 保存推送订阅(新增/更新)。
func (c *Controller) SubscriptionSave(ctx context.Context, req *recruitmentv1.RecruitmentSubscriptionSaveReq) (res *recruitmentv1.RecruitmentSubscriptionSaveRes, err error) {
id, err := recruitment.Recruitment().SubscriptionSave(ctx, dto.SubscriptionInput{
Id: req.Id, Name: req.Name, DeviceKey: req.DeviceKey, Regions: req.Regions,
Categories: req.Categories, OnlyNew: req.OnlyNew, PushTime: req.PushTime, Enabled: req.Enabled,
})
if err != nil {
return nil, err
}
return &recruitmentv1.RecruitmentSubscriptionSaveRes{Id: id}, nil
}
// SubscriptionDelete 删除推送订阅。
func (c *Controller) SubscriptionDelete(ctx context.Context, req *recruitmentv1.RecruitmentSubscriptionDeleteReq) (res *recruitmentv1.RecruitmentSubscriptionDeleteRes, err error) {
if err = recruitment.Recruitment().SubscriptionDelete(ctx, req.Id); err != nil {
return nil, err
}
return &recruitmentv1.RecruitmentSubscriptionDeleteRes{}, nil
}

View File

@ -0,0 +1,63 @@
// Package dao 招聘考试聚合模块数据访问层(手写,未跑 gf gen
// 仅暴露 Ctx(ctx) *gdb.Model供 service 层链式调用,形态与生成产物一致。
// 全部表落在独立数据库 recruitment由 database.recruitment.link 配置)。
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// dbGroup 招聘模块独立数据库分组名,对应 config 中 database.recruitment.link。
const dbGroup = "recruitment"
// model 返回指向指定表的安全 Model。
func model(table string) func(ctx context.Context) *gdb.Model {
return func(ctx context.Context) *gdb.Model {
return g.DB(dbGroup).Model(table).Safe()
}
}
type recruitmentInfoDao struct{ table string }
// RecruitmentInfo 公告主表全局访问对象。
var RecruitmentInfo = recruitmentInfoDao{table: "recruitment_info"}
func (d recruitmentInfoDao) Ctx(ctx context.Context) *gdb.Model { return model(d.table)(ctx) }
type organizationDao struct{ table string }
// Organization 发布主体表全局访问对象。
var Organization = organizationDao{table: "organization"}
func (d organizationDao) Ctx(ctx context.Context) *gdb.Model { return model(d.table)(ctx) }
type crawlSourceDao struct{ table string }
// CrawlSource 数据源表全局访问对象。
var CrawlSource = crawlSourceDao{table: "crawl_source"}
func (d crawlSourceDao) Ctx(ctx context.Context) *gdb.Model { return model(d.table)(ctx) }
type crawlLogDao struct{ table string }
// CrawlLog 抓取日志表全局访问对象。
var CrawlLog = crawlLogDao{table: "crawl_log"}
func (d crawlLogDao) Ctx(ctx context.Context) *gdb.Model { return model(d.table)(ctx) }
type pushSubscriptionDao struct{ table string }
// PushSubscription 推送订阅表全局访问对象。
var PushSubscription = pushSubscriptionDao{table: "push_subscription"}
func (d pushSubscriptionDao) Ctx(ctx context.Context) *gdb.Model { return model(d.table)(ctx) }
type pushLogDao struct{ table string }
// PushLog 推送记录表全局访问对象。
var PushLog = pushLogDao{table: "push_log"}
func (d pushLogDao) Ctx(ctx context.Context) *gdb.Model { return model(d.table)(ctx) }

View File

@ -0,0 +1,102 @@
// Package do 定义招聘考试聚合模块的数据对象(手写,未跑 gf gen
// 字段类型统一用 any与 gf gen 产物保持一致,便于 .Data()/.Where() 使用。
package do
import (
"github.com/gogf/gf/v2/frame/g"
)
// RecruitmentInfo 公告主表 DO。
type RecruitmentInfo struct {
g.Meta `orm:"table:recruitment_info, do:true"`
Id any //
Title any // 公告标题
SourceId any // 数据源ID
OrgId any // 发布主体ID
OrgName any // 发布主体名称
Category any // 考试分类
Region any // 地区
PublishDate any // 发布日期
Deadline any // 报名/截止日期
ExamDate any // 笔试日期
Url any // 原文链接
Content any // 正文/摘要
Attachments any // 附件(JSON)
Status any // 0有效 1已更正 2已失效 3已删除
Fingerprint any // 去重指纹
GroupKey any // 跨源同公告聚合键
CreatedAt any //
UpdatedAt any //
}
// Organization 发布主体 DO。
type Organization struct {
g.Meta `orm:"table:organization, do:true"`
Id any //
Name any // 主体名称
Type any // 主体类型
OfficialSite any // 官网
Region any // 地区
CreatedAt any //
UpdatedAt any //
}
// CrawlSource 数据源 DO。
type CrawlSource struct {
g.Meta `orm:"table:crawl_source, do:true"`
Id any //
Name any // 数据源名称
BaseUrl any // 站点基础URL
SourceType any // 1静态 2SPA 3需登录 4附件
Category any // 默认分类
Region any // 默认地区
ListPath any // 列表路径/接口
Config any // 扩展配置(JSON)
Enabled any // 是否启用
CronExpr any // 调度表达式
LastSuccessAt any // 最近成功抓取时间
FailCount any // 连续失败次数
CreatedAt any //
UpdatedAt any //
}
// CrawlLog 抓取日志 DO。
type CrawlLog struct {
g.Meta `orm:"table:crawl_log, do:true"`
Id any //
SourceId any // 数据源ID
RunAt any // 运行时间
Fetched any // 抓取条数
NewCount any // 新增条数
UpdatedCount any // 更新条数
Error any // 错误信息
CreatedAt any //
}
// PushSubscription 推送订阅 DO。
type PushSubscription struct {
g.Meta `orm:"table:push_subscription, do:true"`
Id any //
Name any // 订阅名称
DeviceKey any // Bark 设备密钥
Regions any // 订阅地区(JSON)
Categories any // 订阅分类(JSON)
OnlyNew any // 仅推送新公告
PushTime any // 推送时间
Enabled any // 是否启用
CreatedAt any //
UpdatedAt any //
}
// PushLog 推送记录 DO。
type PushLog struct {
g.Meta `orm:"table:push_log, do:true"`
Id any //
SubscriptionId any // 订阅ID
PushAt any // 推送时间
Title any // 推送标题
Body any // 推送内容
Result any // 1成功 0失败
Error any // 错误信息
CreatedAt any //
}

View File

@ -0,0 +1,161 @@
// Package dto 招聘考试聚合模块的服务边界对象(入参/出参)。
package dto
// RecruitmentInfoVO 公告列表/详情的出参视图(由 entity 映射,含派生字段)。
type RecruitmentInfoVO struct {
Id uint64 `json:"id"`
Title string `json:"title"`
SourceId uint64 `json:"sourceId"`
SourceName string `json:"sourceName"` // 派生:数据源名称
OrgId uint64 `json:"orgId"`
OrgName string `json:"orgName"`
Category int `json:"category"`
CategoryName string `json:"categoryName"` // 派生:分类中文名
Region string `json:"region"`
PublishDate string `json:"publishDate"`
Deadline string `json:"deadline"`
ExamDate string `json:"examDate"`
Url string `json:"url"`
Content string `json:"content"`
Attachments string `json:"attachments"`
Status int `json:"status"`
StatusName string `json:"statusName"` // 派生:状态中文名
GroupKey string `json:"groupKey"`
CreatedAt string `json:"createdAt"`
}
// RecruitmentFilter 公告查询筛选条件(列表/看板共用)。
type RecruitmentFilter struct {
Page int // 页码,从 1 开始
Size int // 每页大小
Region string // 地区过滤
Category int // 分类(0 表示全部)
Keyword string // 标题/正文/单位关键字
Status int // 状态(0 表示有效+已更正,即未失效)
DateFrom string // 发布日期起点 YYYY-MM-DD
DateTo string // 发布日期终点 YYYY-MM-DD
OrgName string // 发布主体名称模糊匹配
SourceId uint64 // 指定数据源
OnlyNewToday bool // 仅查当日新增
}
// RecruitmentInput 公告写入入参(爬虫/手动录入共用)。
type RecruitmentInput struct {
Id uint64 // 更新时必填
Title string
SourceId uint64
OrgId uint64
OrgName string
Category int
Region string
PublishDate string
Deadline string
ExamDate string
Url string
Content string
Attachments string
Status int
GroupKey string
Fingerprint string
}
// CategoryAgg 按分类聚合。
type CategoryAgg struct {
Category int `json:"category"`
CategoryName string `json:"categoryName"`
Count int `json:"count"`
}
// RegionAgg 按地区聚合。
type RegionAgg struct {
Region string `json:"region"`
Count int `json:"count"`
}
// TrendPoint 按日新增趋势点。
type TrendPoint struct {
Date string `json:"date"`
Count int `json:"count"`
}
// RecruitmentStats 看板统计数据。
type RecruitmentStats struct {
Total int `json:"total"` // 有效公告总数
TodayNew int `json:"todayNew"` // 今日新增
WeekNew int `json:"weekNew"` // 近7天新增
ByCategory []CategoryAgg `json:"byCategory"`
ByRegion []RegionAgg `json:"byRegion"`
RecentTrend []TrendPoint `json:"recentTrend"` // 近30天每日新增
}
// SourceItem 数据源状态视图。
type SourceItem struct {
Id uint64 `json:"id"`
Name string `json:"name"`
BaseUrl string `json:"baseUrl"`
SourceType int `json:"sourceType"`
Category int `json:"category"`
Region string `json:"region"`
Enabled int `json:"enabled"`
LastSuccessAt string `json:"lastSuccessAt"`
FailCount int `json:"failCount"`
LastSummary string `json:"lastSummary"` // 最近一次运行摘要
}
// SubscriptionItem 推送订阅视图。
type SubscriptionItem struct {
Id uint64 `json:"id"`
Name string `json:"name"`
DeviceKey string `json:"deviceKey"`
Regions []string `json:"regions"`
Categories []int `json:"categories"`
OnlyNew int `json:"onlyNew"`
PushTime string `json:"pushTime"`
Enabled int `json:"enabled"`
}
// CrawlRunResult 单次抓取运行结果(用于写日志与返回)。
type CrawlRunResult struct {
SourceId uint64
Fetched int
NewCount int
Updated int
Err error
}
// SubscriptionInput 推送订阅写入入参。
type SubscriptionInput struct {
Id uint64
Name string
DeviceKey string
Regions []string
Categories []int
OnlyNew int
PushTime string
Enabled int
}
// 分类与状态的中文名映射(派生展示用)。
var CategoryNameMap = map[int]string{
1: "公务员", 2: "事业单位", 3: "国企", 4: "央企", 5: "私企", 6: "其他",
}
var StatusNameMap = map[int]string{
0: "有效", 1: "已更正", 2: "已失效", 3: "已删除",
}
// CategoryName 返回分类中文名。
func CategoryName(c int) string {
if v, ok := CategoryNameMap[c]; ok {
return v
}
return "其他"
}
// StatusName 返回状态中文名。
func StatusName(s int) string {
if v, ok := StatusNameMap[s]; ok {
return v
}
return "未知"
}

View File

@ -0,0 +1,92 @@
// Package entity 定义招聘考试聚合模块的数据表结构(手写,未跑 gf gen
// 字段命名对齐 service 现有约定json 用驼峰orm 用下划线。
package entity
// RecruitmentInfo 招聘考试公告主表。
type RecruitmentInfo struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
Title string `json:"title" orm:"title" description:"公告标题"`
SourceId uint64 `json:"sourceId" orm:"source_id" description:"数据源ID"`
OrgId uint64 `json:"orgId" orm:"org_id" description:"发布主体ID"`
OrgName string `json:"orgName" orm:"org_name" description:"发布主体名称(冗余便于展示)"`
Category int `json:"category" orm:"category" description:"考试分类 1公务员 2事业单位 3国企 4央企 5私企 6其他"`
Region string `json:"region" orm:"region" description:"地区(贵阳/省直/其他市州)"`
PublishDate string `json:"publishDate" orm:"publish_date" description:"发布日期"`
Deadline string `json:"deadline" orm:"deadline" description:"报名/截止日期"`
ExamDate string `json:"examDate" orm:"exam_date" description:"笔试日期"`
Url string `json:"url" orm:"url" description:"原文链接"`
Content string `json:"content" orm:"content" description:"正文/摘要"`
Attachments string `json:"attachments" orm:"attachments" description:"附件链接与解析结果(JSON)"`
Status int `json:"status" orm:"status" description:"0有效 1已更正 2已失效 3已删除"`
Fingerprint string `json:"fingerprint" orm:"fingerprint" description:"去重指纹"`
GroupKey string `json:"groupKey" orm:"group_key" description:"跨源同公告聚合键"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"入库时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// Organization 发布主体(机关/事业单位/国企/央企/私企)。
type Organization struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
Name string `json:"name" orm:"name" description:"主体名称"`
Type int `json:"type" orm:"type" description:"主体类型 1机关 2事业 3国企 4央企 5私企 6其他"`
OfficialSite string `json:"officialSite" orm:"official_site" description:"官网"`
Region string `json:"region" orm:"region" description:"地区"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// CrawlSource 数据源配置(插件化爬虫适配器)。
type CrawlSource struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
Name string `json:"name" orm:"name" description:"数据源名称"`
BaseUrl string `json:"baseUrl" orm:"base_url" description:"站点基础URL"`
SourceType int `json:"sourceType" orm:"source_type" description:"1静态列表 2SPA 3需登录 4附件型"`
Category int `json:"category" orm:"category" description:"该源默认考试分类"`
Region string `json:"region" orm:"region" description:"该源默认地区"`
ListPath string `json:"listPath" orm:"list_path" description:"列表页路径/接口"`
Config string `json:"config" orm:"config" description:"适配器扩展配置(JSON)"`
Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"`
CronExpr string `json:"cronExpr" orm:"cron_expr" description:"调度表达式(可选)"`
LastSuccessAt string `json:"lastSuccessAt" orm:"last_success_at" description:"最近成功抓取时间"`
FailCount int `json:"failCount" orm:"fail_count" description:"连续失败次数"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// CrawlLog 抓取任务运行日志。
type CrawlLog struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
SourceId uint64 `json:"sourceId" orm:"source_id" description:"数据源ID"`
RunAt string `json:"runAt" orm:"run_at" description:"运行时间"`
Fetched int `json:"fetched" orm:"fetched" description:"本次抓取条数"`
NewCount int `json:"newCount" orm:"new_count" description:"新增条数"`
UpdatedCount int `json:"updatedCount" orm:"updated_count" description:"更新条数"`
Error string `json:"error" orm:"error" description:"错误信息"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
}
// PushSubscription Bark 推送订阅配置。
type PushSubscription struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
Name string `json:"name" orm:"name" description:"订阅名称"`
DeviceKey string `json:"deviceKey" orm:"device_key" description:"Bark 设备密钥"`
Regions string `json:"regions" orm:"regions" description:"订阅地区(JSON数组)"`
Categories string `json:"categories" orm:"categories" description:"订阅分类(JSON数组)"`
OnlyNew int `json:"onlyNew" orm:"only_new" description:"仅推送新公告 0否 1是"`
PushTime string `json:"pushTime" orm:"push_time" description:"推送时间 HH:mm"`
Enabled int `json:"enabled" orm:"enabled" description:"是否启用 0否 1是"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// PushLog 推送记录。
type PushLog struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
SubscriptionId uint64 `json:"subscriptionId" orm:"subscription_id" description:"订阅ID"`
PushAt string `json:"pushAt" orm:"push_at" description:"推送时间"`
Title string `json:"title" orm:"title" description:"推送标题"`
Body string `json:"body" orm:"body" description:"推送内容"`
Result int `json:"result" orm:"result" description:"1成功 0失败"`
Error string `json:"error" orm:"error" description:"错误信息"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
}

View File

@ -0,0 +1,55 @@
// Package house_transaction 提供成交记录领域服务。
package house_transaction
import (
"context"
"github.com/gogf/gf/v2/errors/gerror"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/model/dto"
)
// ITransaction 成交记录领域服务接口。
type ITransaction interface {
List(context.Context, int, int, string, string) ([]dto.HouseTransactionVO, int, error)
}
type transaction struct{}
var localTransaction ITransaction
func NewTransaction() ITransaction { return &transaction{} }
// Transaction 返回已注册的成交服务实现。
func Transaction() ITransaction {
if localTransaction == nil {
panic("Transaction implementation not registered")
}
return localTransaction
}
// RegisterTransaction 注册成交服务实现。
func RegisterTransaction(i ITransaction) { localTransaction = i }
// List 分页查询成交记录,关联小区名,按成交日期倒序。
func (s *transaction) List(ctx context.Context, page, size int, region, keyword string) ([]dto.HouseTransactionVO, int, error) {
m := dao.HouseTransaction.Ctx(ctx).As("t").
LeftJoin("house_community c", "t.community_id=c.id").
Fields("t.*, c.name AS community_name")
if region != "" {
m = m.Where("c.region", region)
}
if keyword != "" {
m = m.Where("c.name LIKE ? OR t.layout LIKE ?", "%"+keyword+"%", "%"+keyword+"%")
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "count transaction")
}
var list []dto.HouseTransactionVO
if err = m.Clone().Page(page, size).OrderDesc("t.deal_date").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query transaction list")
}
return list, total, nil
}

View File

@ -0,0 +1,322 @@
// 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)
}

View File

@ -0,0 +1,11 @@
-- 012_house_transaction_menu.sql
-- 成交记录查询权限挂到数据明细菜单 94
-- 幂等INSERT ... ON DUPLICATE KEY UPDATE + INSERT IGNORE可重复执行
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(941, 94, '成交查询', '', 2, 'POST /api/service/admin/house/transaction/list', '', 'house:data:transaction', 2, 1, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), name=VALUES(name), type=VALUES(type),
path=VALUES(path), permission=VALUES(permission), sort=VALUES(sort), status=VALUES(status), hidden=VALUES(hidden), deleted_at=NULL;
INSERT IGNORE INTO admin_role_menu (role_id, menu_id, created_at, updated_at)
SELECT 1, id, NOW(), NOW() FROM admin_menu WHERE id IN (941);