diff --git a/api/recruitment/v1/recruitment.go b/api/recruitment/v1/recruitment.go new file mode 100644 index 0000000..da2a156 --- /dev/null +++ b/api/recruitment/v1/recruitment.go @@ -0,0 +1,191 @@ +// Package recruitment_v1 招聘考试聚合模块接口契约。 +// 规范(2026-08-27):全部 POST;URL 不含任何参数(查询/路径参数均禁止);入参一律走 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{} diff --git a/internal/controller/recruitment/controller.go b/internal/controller/recruitment/controller.go new file mode 100644 index 0000000..49abf1b --- /dev/null +++ b/internal/controller/recruitment/controller.go @@ -0,0 +1,8 @@ +// Package recruitment 实现招聘考试聚合模块管理端点,绑定在 admin 受权限保护分组下。 +package recruitment + +// Controller 实现招聘考试聚合模块的所有端点。 +type Controller struct{} + +// New 创建招聘模块控制器。 +func New() *Controller { return &Controller{} } diff --git a/internal/controller/recruitment/recruitment.go b/internal/controller/recruitment/recruitment.go new file mode 100644 index 0000000..e4cf388 --- /dev/null +++ b/internal/controller/recruitment/recruitment.go @@ -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 +} diff --git a/internal/dao/recruitment.go b/internal/dao/recruitment.go new file mode 100644 index 0000000..e9707e1 --- /dev/null +++ b/internal/dao/recruitment.go @@ -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) } diff --git a/internal/model/do/recruitment.go b/internal/model/do/recruitment.go new file mode 100644 index 0000000..f22aba0 --- /dev/null +++ b/internal/model/do/recruitment.go @@ -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 // +} diff --git a/internal/model/dto/recruitment.go b/internal/model/dto/recruitment.go new file mode 100644 index 0000000..97c27e9 --- /dev/null +++ b/internal/model/dto/recruitment.go @@ -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 "未知" +} diff --git a/internal/model/entity/recruitment.go b/internal/model/entity/recruitment.go new file mode 100644 index 0000000..b6de122 --- /dev/null +++ b/internal/model/entity/recruitment.go @@ -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:"创建时间"` +} diff --git a/internal/service/house/transaction/transaction.go b/internal/service/house/transaction/transaction.go new file mode 100644 index 0000000..f415267 --- /dev/null +++ b/internal/service/house/transaction/transaction.go @@ -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 +} diff --git a/internal/service/recruitment/recruitment.go b/internal/service/recruitment/recruitment.go new file mode 100644 index 0000000..6bfd2bd --- /dev/null +++ b/internal/service/recruitment/recruitment.go @@ -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(®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) +} diff --git a/manifest/sql/012_house_transaction_menu.sql b/manifest/sql/012_house_transaction_menu.sql new file mode 100644 index 0000000..1f3c385 --- /dev/null +++ b/manifest/sql/012_house_transaction_menu.sql @@ -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);