feat(notice+job): 通知模块(渠道/规则/日志/测试) + 自动任务管理(DB驱动gcron) + 用户表加Bark/pushplus字段

This commit is contained in:
夏犀麟 2026-08-27 22:58:33 +08:00
parent e18b25f32a
commit 0569c70690
20 changed files with 1559 additions and 78 deletions

View File

@ -1,6 +1,8 @@
# service.xpcool.com 变更记录 # service.xpcool.com 变更记录
> 倒序最新在上格式YYYY-MM-DD | 类型 | 摘要 > 倒序最新在上格式YYYY-MM-DD | 类型 | 摘要
2026-08-27 | FIX | 菜单种子主键冲突修复743683erecruitment/003_menu.sql 招聘中心一级菜单 id=95 014_house_presale_menu.sql 新房预售 id=95挂看房中心 90ON DUPLICATE 互相覆盖后导者赢本地实际发生新房预售菜单被顶掉致 /house/presale 404生产库同样风险改为 id=96 后发现 015_server_security.sql并行会话生产已部署也用 96=安全日志挂系统监控5再改 **97**95=新房预售(挂90)96=安全日志(挂5)97=招聘中心三模块错开本地按 014015003 重放routes 接口 22 节点验证全部正确e18b25f 生产库需按 014015003 顺序重放修复当前生产 95=招聘中心新房预售菜单丢失
2026-08-27 | CHG | 服务器安全日志模块015 SQL server_security_log + 菜单(96安全日志/960查询/961统计)api/serversecurity/v1 三接口全 POSTopen 上报 /api/service/open/security/log/reportbody token+list校验 internalToken=INTERNAL_TOKEN env+ admin 查询 /server-security/log/list统计 /server-security/log/stats时间/IP/类型/端口筛选+TOP攻击源entity/do/dao(主库)/dto/service/controllerReportController+ManageController 双控制器cmd.go 注入 INTERNAL_TOKENinternalTokenopen 组绑 NewReportprotected 组绑 NewManagego build 通过交叉编译上传 + 服务器 docker build + 容器重建复用 env 追加 INTERNAL_TOKEN路由/上报验证 OK错误 token 返回 10002 2026-08-27 | CHG | 服务器安全日志模块015 SQL server_security_log + 菜单(96安全日志/960查询/961统计)api/serversecurity/v1 三接口全 POSTopen 上报 /api/service/open/security/log/reportbody token+list校验 internalToken=INTERNAL_TOKEN env+ admin 查询 /server-security/log/list统计 /server-security/log/stats时间/IP/类型/端口筛选+TOP攻击源entity/do/dao(主库)/dto/service/controllerReportController+ManageController 双控制器cmd.go 注入 INTERNAL_TOKENinternalTokenopen 组绑 NewReportprotected 组绑 NewManagego build 通过交叉编译上传 + 服务器 docker build + 容器重建复用 env 追加 INTERNAL_TOKEN路由/上报验证 OK错误 token 返回 10002
2026-08-27 | FIX | 本地登录 500 修复根因=config.dev.yaml ${RECRUITMENT_DB_DSN} 占位符在本地无对应 envinjectEnv 只回填已有变量gdb 配置解析失败致 g.DB() 全局报 invalid link configuration本地补建 recruitment 导入 001_init+002_seed_sources6 5 +.env.dev .run/service-dev.run.xml RECRUITMENT_DB_DSN两文件 gitignore 不入库附带修复 gcron 5 段式表达式注册失败invalid pattern "0 3 * * *" 6 段式 '0 0 3 * * *'/'0 0 8 * * *'此前线上每日抓取/推送 cron 实际从未执行f81ce64验证重启后登录返回业务码 30002非500 cron 报错 2026-08-27 | FIX | 本地登录 500 修复根因=config.dev.yaml ${RECRUITMENT_DB_DSN} 占位符在本地无对应 envinjectEnv 只回填已有变量gdb 配置解析失败致 g.DB() 全局报 invalid link configuration本地补建 recruitment 导入 001_init+002_seed_sources6 5 +.env.dev .run/service-dev.run.xml RECRUITMENT_DB_DSN两文件 gitignore 不入库附带修复 gcron 5 段式表达式注册失败invalid pattern "0 3 * * *" 6 段式 '0 0 3 * * *'/'0 0 8 * * *'此前线上每日抓取/推送 cron 实际从未执行f81ce64验证重启后登录返回业务码 30002非500 cron 报错
2026-08-27 | CHG | 看房新增新房预售证模块api/house/presale + controller/servicePOST /api/service/admin/house/presale/list筛选 region/purpose/keywordid 倒序dto HousePresaleVOhouse_presale publish_date 字段013 SQL 同步 + hack/config.yaml tables house_presale + gen dao 更新权限种子 014菜单 95 新房预售 + 950 查询权限go build 通过接口返回 487 条干净数据presaleNo/publishDate 正确 2026-08-27 | CHG | 看房新增新房预售证模块api/house/presale + controller/servicePOST /api/service/admin/house/presale/list筛选 region/purpose/keywordid 倒序dto HousePresaleVOhouse_presale publish_date 字段013 SQL 同步 + hack/config.yaml tables house_presale + gen dao 更新权限种子 014菜单 95 新房预售 + 950 查询权限go build 通过接口返回 487 条干净数据presaleNo/publishDate 正确

73
api/job/v1/job.go Normal file
View File

@ -0,0 +1,73 @@
// Package job_v1 自动任务管理模块接口契约DB 驱动的 gcron 调度 + 运行记录)。
// 规范2026-08-27全部 POSTURL 不含参数;入参一律 body。
package job_v1
import "github.com/gogf/gf/v2/frame/g"
// ---------- 任务列表 ----------
type JobItem struct {
Id uint64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
JobType string `json:"jobType"`
CronExpr string `json:"cronExpr"`
Enabled int `json:"enabled"`
Remark string `json:"remark"`
LastRunAt string `json:"lastRunAt"`
LastResult int `json:"lastResult"` // 0未运行 1成功 2失败
LastError string `json:"lastError"`
}
type AutoJobListReq struct {
g.Meta `path:"/auto-job/list" method:"post" tags:"Admin/AutoJob" summary:"自动任务列表"`
}
type AutoJobListRes struct {
List []*JobItem `json:"list"`
}
// ---------- 保存任务(改 cron/启用/备注) ----------
type AutoJobSaveReq struct {
g.Meta `path:"/auto-job/save" method:"post" tags:"Admin/AutoJob" summary:"保存自动任务(改cron/启用)"`
Id uint64 `json:"id" v:"required"`
CronExpr string `json:"cronExpr"`
Enabled int `json:"enabled" d:"1"`
Remark string `json:"remark"`
}
type AutoJobSaveRes struct{}
// ---------- 手动触发 ----------
type AutoJobTriggerReq struct {
g.Meta `path:"/auto-job/trigger" method:"post" tags:"Admin/AutoJob" summary:"手动触发任务"`
Id uint64 `json:"id" v:"required"`
}
type AutoJobTriggerRes struct {
Summary string `json:"summary"`
Ok bool `json:"ok"`
}
// ---------- 任务运行日志 ----------
type JobLogItem struct {
Id uint64 `json:"id"`
JobId uint64 `json:"jobId"`
JobName string `json:"jobName"`
RunAt string `json:"runAt"`
Result int `json:"result"`
Error string `json:"error"`
Summary string `json:"summary"`
DurationMs int `json:"durationMs"`
}
type AutoJobLogListReq struct {
g.Meta `path:"/auto-job/log/list" method:"post" tags:"Admin/AutoJob" summary:"任务运行日志分页"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
JobId uint64 `json:"jobId"` // 0=全部
}
type AutoJobLogListRes struct {
List []*JobLogItem `json:"list"`
Total int `json:"total"`
}

128
api/notice/v1/notice.go Normal file
View File

@ -0,0 +1,128 @@
// Package notice_v1 通知模块接口契约(统一推送管理:渠道/规则/日志/测试)。
// 规范2026-08-27全部 POSTURL 不含参数;入参一律 body。
package notice_v1
import "github.com/gogf/gf/v2/frame/g"
// ---------- 通知渠道 ----------
type NoticeChannelItem struct {
Id uint64 `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Enabled int `json:"enabled"`
Config string `json:"config"` // JSON 字符串
Remark string `json:"remark"`
}
type NoticeChannelListReq struct {
g.Meta `path:"/notice/channel/list" method:"post" tags:"Admin/Notice" summary:"通知渠道列表"`
}
type NoticeChannelListRes struct {
List []*NoticeChannelItem `json:"list"`
}
type NoticeChannelSaveReq struct {
g.Meta `path:"/notice/channel/save" method:"post" tags:"Admin/Notice" summary:"保存通知渠道(新增/更新)"`
Id uint64 `json:"id"` // 0=新增
Code string `json:"code" v:"required"`
Name string `json:"name" v:"required"`
Enabled int `json:"enabled" d:"1"`
Config string `json:"config"`
Remark string `json:"remark"`
}
type NoticeChannelSaveRes struct {
Id uint64 `json:"id"`
}
// ---------- 通知规则 ----------
type NoticeRuleItem struct {
Id uint64 `json:"id"`
Name string `json:"name"`
EventType string `json:"eventType"`
ChannelCodes []string `json:"channelCodes"`
UserIds []uint64 `json:"userIds"`
TitleTemplate string `json:"titleTemplate"`
BodyTemplate string `json:"bodyTemplate"`
Enabled int `json:"enabled"`
}
type NoticeRuleListReq struct {
g.Meta `path:"/notice/rule/list" method:"post" tags:"Admin/Notice" summary:"通知规则列表"`
EventType string `json:"eventType"` // 可选过滤
}
type NoticeRuleListRes struct {
List []*NoticeRuleItem `json:"list"`
}
type NoticeRuleSaveReq struct {
g.Meta `path:"/notice/rule/save" method:"post" tags:"Admin/Notice" summary:"保存通知规则(新增/更新)"`
Id uint64 `json:"id"` // 0=新增
Name string `json:"name" v:"required"`
EventType string `json:"eventType" v:"required"`
ChannelCodes []string `json:"channelCodes" v:"required|min-length:1"`
UserIds []uint64 `json:"userIds" v:"required|min-length:1"`
TitleTemplate string `json:"titleTemplate"`
BodyTemplate string `json:"bodyTemplate"`
Enabled int `json:"enabled" d:"1"`
}
type NoticeRuleSaveRes struct {
Id uint64 `json:"id"`
}
type NoticeRuleDeleteReq struct {
g.Meta `path:"/notice/rule/delete" method:"post" tags:"Admin/Notice" summary:"删除通知规则"`
Id uint64 `json:"id" v:"required"`
}
type NoticeRuleDeleteRes struct{}
// ---------- 通知日志 ----------
type NoticeLogItem struct {
Id uint64 `json:"id"`
RuleId uint64 `json:"ruleId"`
EventType string `json:"eventType"`
ChannelCode string `json:"channelCode"`
UserId uint64 `json:"userId"`
Target string `json:"target"`
Title string `json:"title"`
Body string `json:"body"`
Result int `json:"result"`
Error string `json:"error"`
CreatedAt string `json:"createdAt"`
}
type NoticeLogListReq struct {
g.Meta `path:"/notice/log/list" method:"post" tags:"Admin/Notice" summary:"通知发送记录分页"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
EventType string `json:"eventType"`
ChannelCode string `json:"channelCode"`
Result int `json:"result"` // 0全部 1成功 2失败
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
}
type NoticeLogListRes struct {
List []*NoticeLogItem `json:"list"`
Total int `json:"total"`
}
// ---------- 测试发送 ----------
type NoticeTestReq struct {
g.Meta `path:"/notice/test" method:"post" tags:"Admin/Notice" summary:"测试通知(按规则或直接指定)"`
RuleId uint64 `json:"ruleId"` // 按规则测试(0=手动指定)
ChannelCode string `json:"channelCode"` // 手动:渠道编码
Target string `json:"target"` // 手动设备key/token
UserIds []uint64 `json:"userIds"` // 手动:或按用户
Title string `json:"title"`
Body string `json:"body"`
}
type NoticeTestRes struct {
Result bool `json:"result"`
Message string `json:"message"`
}

View File

@ -4,15 +4,19 @@ import (
"context" "context"
"github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gcfg" "github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/os/gcmd"
"github.com/gogf/gf/v2/os/genv" "github.com/gogf/gf/v2/os/genv"
"github.com/gogf/gf/v2/text/gstr"
adminctl "service.xpcool.com/internal/controller/admin" adminctl "service.xpcool.com/internal/controller/admin"
housectl "service.xpcool.com/internal/controller/house" housectl "service.xpcool.com/internal/controller/house"
openctl "service.xpcool.com/internal/controller/open" openctl "service.xpcool.com/internal/controller/open"
recruitmentctl "service.xpcool.com/internal/controller/recruitment" recruitmentctl "service.xpcool.com/internal/controller/recruitment"
noticectl "service.xpcool.com/internal/controller/notice"
jobctl "service.xpcool.com/internal/controller/job"
serversecurityctl "service.xpcool.com/internal/controller/serversecurity" serversecurityctl "service.xpcool.com/internal/controller/serversecurity"
userctl "service.xpcool.com/internal/controller/user" userctl "service.xpcool.com/internal/controller/user"
"service.xpcool.com/internal/library/jwt" "service.xpcool.com/internal/library/jwt"
@ -32,40 +36,59 @@ import (
housetransaction "service.xpcool.com/internal/service/house/transaction" housetransaction "service.xpcool.com/internal/service/house/transaction"
housepresale "service.xpcool.com/internal/service/house/presale" housepresale "service.xpcool.com/internal/service/house/presale"
recruitmentsvc "service.xpcool.com/internal/service/recruitment" recruitmentsvc "service.xpcool.com/internal/service/recruitment"
noticesvc "service.xpcool.com/internal/service/notice"
jobsvc "service.xpcool.com/internal/service/job"
serversecuritysvc "service.xpcool.com/internal/service/serversecurity" serversecuritysvc "service.xpcool.com/internal/service/serversecurity"
) )
// 开发环境默认值任何启动方式GoLand / 命令行 / go run漏注入环境变量时兜底
// 避免 ${DB_DSN} 占位符原样传入 gdb 导致全站接口 500。
// 生产GF_GCFG_ENV=prod不启用兜底配置缺失应显性报错。
const (
devDefaultDBDSN = "mysql:root:root123@tcp(127.0.0.1:3306)/service_xpcool_com?loc=Local"
devDefaultRecruitmentDSN = "mysql:root:root123@tcp(127.0.0.1:3306)/recruitment?loc=Local"
devDefaultJWTSecret = "dev-only-secret-not-for-production"
)
// setIfEmpty 仅当 env 存在时写入配置优先级env > 配置文件默认值)。
func setIfEmpty(adapter *gcfg.AdapterFile, key, envName string) {
if v := genv.Get(envName); !v.IsEmpty() {
_ = adapter.Set(key, v.String())
}
}
// injectEnv 手动把关键环境变量写入配置系统。 // injectEnv 手动把关键环境变量写入配置系统。
// 注意gf v2.10.2 起配置不再自动替换 ${ENV} 占位符,需在此显式注入, // 注意gf v2.10.2 起配置不再自动替换 ${ENV} 占位符,需在此显式注入,
// 否则 config.dev.yaml 中的 ${DB_DSN}/${JWT_SECRET} 会原样传给数据库与 JWT。 // 否则 config.dev.yaml 中的 ${DB_DSN}/${JWT_SECRET} 会原样传给数据库与 JWT。
// 开发环境下若 env 缺失,回填本地默认值,保证「零配置可启动」。
func injectEnv(ctx context.Context) { func injectEnv(ctx context.Context) {
adapter, ok := g.Cfg().GetAdapter().(*gcfg.AdapterFile) adapter, ok := g.Cfg().GetAdapter().(*gcfg.AdapterFile)
if !ok { if !ok {
return return
} }
if v := genv.Get("DB_DSN"); !v.IsEmpty() { isProd := genv.Get("GF_GCFG_ENV").String() == "prod"
_ = adapter.Set("database.default.link", v.String()) if !isProd {
} // 开发兜底:仅当配置项仍是未解析的 ${...} 占位符时才写默认值,
if v := genv.Get("JWT_SECRET"); !v.IsEmpty() { // 不覆盖 .env.dev / GoLand 运行配置注入的真实值。
_ = adapter.Set("jwt.secret", v.String()) if v, _ := adapter.Get(ctx, "database.default.link"); v != nil && gstr.Contains(gconv.String(v), "${") {
_ = adapter.Set("database.default.link", devDefaultDBDSN)
}
if v, _ := adapter.Get(ctx, "database.recruitment.link"); v != nil && gstr.Contains(gconv.String(v), "${") {
_ = adapter.Set("database.recruitment.link", devDefaultRecruitmentDSN)
}
if v, _ := adapter.Get(ctx, "jwt.secret"); v != nil && gstr.Contains(gconv.String(v), "${") {
_ = adapter.Set("jwt.secret", devDefaultJWTSecret)
}
} }
setIfEmpty(adapter, "database.default.link", "DB_DSN")
setIfEmpty(adapter, "jwt.secret", "JWT_SECRET")
// 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。 // 招聘模块独立数据库与 Bark 推送配置(自建 Bark 服务)。
if v := genv.Get("RECRUITMENT_DB_DSN"); !v.IsEmpty() { setIfEmpty(adapter, "database.recruitment.link", "RECRUITMENT_DB_DSN")
_ = adapter.Set("database.recruitment.link", v.String()) setIfEmpty(adapter, "bark.baseUrl", "BARK_BASE_URL")
} setIfEmpty(adapter, "bark.deviceKey", "BARK_DEVICE_KEY")
if v := genv.Get("BARK_BASE_URL"); !v.IsEmpty() { setIfEmpty(adapter, "bark.pushTime", "BARK_PUSH_TIME")
_ = adapter.Set("bark.baseUrl", v.String())
}
if v := genv.Get("BARK_DEVICE_KEY"); !v.IsEmpty() {
_ = adapter.Set("bark.deviceKey", v.String())
}
if v := genv.Get("BARK_PUSH_TIME"); !v.IsEmpty() {
_ = adapter.Set("bark.pushTime", v.String())
}
// 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。 // 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。
if v := genv.Get("INTERNAL_TOKEN"); !v.IsEmpty() { setIfEmpty(adapter, "internalToken", "INTERNAL_TOKEN")
_ = adapter.Set("internalToken", v.String())
}
} }
var ( var (
@ -92,6 +115,8 @@ var (
housetransaction.RegisterTransaction(housetransaction.NewTransaction()) housetransaction.RegisterTransaction(housetransaction.NewTransaction())
housepresale.RegisterPresale(housepresale.NewPresale()) housepresale.RegisterPresale(housepresale.NewPresale())
recruitmentsvc.RegisterRecruitment(recruitmentsvc.New()) recruitmentsvc.RegisterRecruitment(recruitmentsvc.New())
noticesvc.RegisterNotice(noticesvc.New())
jobsvc.RegisterJob(jobsvc.New())
serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New()) serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New())
s.Group("/api/service/open", func(group *ghttp.RouterGroup) { s.Group("/api/service/open", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse) group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
@ -120,11 +145,14 @@ var (
protected.Bind(adminctl.New()) protected.Bind(adminctl.New())
protected.Bind(housectl.New()) protected.Bind(housectl.New())
protected.Bind(recruitmentctl.New()) protected.Bind(recruitmentctl.New())
protected.Bind(noticectl.New())
protected.Bind(jobctl.New())
protected.Bind(serversecurityctl.NewManage()) protected.Bind(serversecurityctl.NewManage())
}) })
}) })
// 启动招聘模块定时任务(每日增量抓取 + 早报推送)。 // 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。
recruitmentsvc.StartScheduler(ctx) recruitmentsvc.RegisterTasks()
jobsvc.Job().StartScheduler(ctx)
s.Run() s.Run()
return nil return nil
}, },

View File

@ -27,7 +27,8 @@ func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) (
// AdminCreate 创建管理员。 // AdminCreate 创建管理员。
func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) { func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) {
id, err := admin.AdminManage().Create(ctx, dto.AdminCreateInput{ id, err := admin.AdminManage().Create(ctx, dto.AdminCreateInput{
Username: req.Username, Password: req.Password, Nickname: req.Nickname, RoleIds: req.RoleIds, Username: req.Username, Password: req.Password, Nickname: req.Nickname,
BarkDeviceId: req.BarkDeviceId, PushplusToken: req.PushplusToken, RoleIds: req.RoleIds,
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -37,7 +38,8 @@ func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateRe
// AdminUpdate 更新管理员。 // AdminUpdate 更新管理员。
func (c *Controller) AdminUpdate(ctx context.Context, req *adminv1.AdminUpdateReq) (res *adminv1.AdminUpdateRes, err error) { func (c *Controller) AdminUpdate(ctx context.Context, req *adminv1.AdminUpdateReq) (res *adminv1.AdminUpdateRes, err error) {
if err = admin.AdminManage().Update(ctx, dto.AdminUpdateInput{Id: req.Id, Nickname: req.Nickname, Status: req.Status, RoleIds: req.RoleIds}); err != nil { if err = admin.AdminManage().Update(ctx, dto.AdminUpdateInput{Id: req.Id, Nickname: req.Nickname, Status: req.Status,
BarkDeviceId: req.BarkDeviceId, PushplusToken: req.PushplusToken, RoleIds: req.RoleIds}); err != nil {
return nil, err return nil, err
} }
return &adminv1.AdminUpdateRes{}, nil return &adminv1.AdminUpdateRes{}, nil

View File

@ -0,0 +1,68 @@
// Package job 自动任务模块控制器(绑定 admin 受权限保护组)。
package job
import (
"context"
jobv1 "service.xpcool.com/api/job/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service/job"
)
// Controller 实现自动任务端点。
type Controller struct{}
// New 创建自动任务控制器。
func New() *Controller { return &Controller{} }
// List 自动任务列表。
func (c *Controller) List(ctx context.Context, req *jobv1.AutoJobListReq) (res *jobv1.AutoJobListRes, err error) {
list, err := job.Job().List(ctx)
if err != nil {
return nil, err
}
out := make([]*jobv1.JobItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &jobv1.JobItem{
Id: v.Id, Name: v.Name, Code: v.Code, JobType: v.JobType, CronExpr: v.CronExpr,
Enabled: v.Enabled, Remark: v.Remark, LastRunAt: v.LastRunAt,
LastResult: v.LastResult, LastError: v.LastError,
})
}
return &jobv1.AutoJobListRes{List: out}, nil
}
// Save 保存自动任务cron/启用/备注)。
func (c *Controller) Save(ctx context.Context, req *jobv1.AutoJobSaveReq) (res *jobv1.AutoJobSaveRes, err error) {
if err = job.Job().Save(ctx, dto.JobInput{Id: req.Id, CronExpr: req.CronExpr, Enabled: req.Enabled, Remark: req.Remark}); err != nil {
return nil, err
}
return &jobv1.AutoJobSaveRes{}, nil
}
// Trigger 手动触发任务。
func (c *Controller) Trigger(ctx context.Context, req *jobv1.AutoJobTriggerReq) (res *jobv1.AutoJobTriggerRes, err error) {
summary, ok, err := job.Job().Trigger(ctx, req.Id)
if err != nil {
return nil, err
}
return &jobv1.AutoJobTriggerRes{Summary: summary, Ok: ok}, nil
}
// LogList 任务运行日志分页。
func (c *Controller) LogList(ctx context.Context, req *jobv1.AutoJobLogListReq) (res *jobv1.AutoJobLogListRes, err error) {
list, total, err := job.Job().LogList(ctx, dto.JobLogFilter{Page: req.Page, Size: req.Size, JobId: req.JobId})
if err != nil {
return nil, err
}
out := make([]*jobv1.JobLogItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &jobv1.JobLogItem{
Id: v.Id, JobId: v.JobId, JobName: v.JobName, RunAt: v.RunAt,
Result: v.Result, Error: v.Error, Summary: v.Summary, DurationMs: v.DurationMs,
})
}
return &jobv1.AutoJobLogListRes{List: out, Total: total}, nil
}

View File

@ -0,0 +1,108 @@
// Package notice 通知模块控制器(绑定 admin 受权限保护组)。
package notice
import (
"context"
noticev1 "service.xpcool.com/api/notice/v1"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service/notice"
)
// Controller 实现通知模块端点。
type Controller struct{}
// New 创建通知控制器。
func New() *Controller { return &Controller{} }
// ChannelList 通知渠道列表。
func (c *Controller) ChannelList(ctx context.Context, req *noticev1.NoticeChannelListReq) (res *noticev1.NoticeChannelListRes, err error) {
list, err := notice.Notice().ChannelList(ctx)
if err != nil {
return nil, err
}
out := make([]*noticev1.NoticeChannelItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &noticev1.NoticeChannelItem{Id: v.Id, Code: v.Code, Name: v.Name, Enabled: v.Enabled, Config: v.Config, Remark: v.Remark})
}
return &noticev1.NoticeChannelListRes{List: out}, nil
}
// ChannelSave 保存通知渠道。
func (c *Controller) ChannelSave(ctx context.Context, req *noticev1.NoticeChannelSaveReq) (res *noticev1.NoticeChannelSaveRes, err error) {
id, err := notice.Notice().ChannelSave(ctx, dto.NoticeChannelInput{
Id: req.Id, Code: req.Code, Name: req.Name, Enabled: req.Enabled, Config: req.Config, Remark: req.Remark,
})
if err != nil {
return nil, err
}
return &noticev1.NoticeChannelSaveRes{Id: id}, nil
}
// RuleList 通知规则列表。
func (c *Controller) RuleList(ctx context.Context, req *noticev1.NoticeRuleListReq) (res *noticev1.NoticeRuleListRes, err error) {
list, err := notice.Notice().RuleList(ctx, req.EventType)
if err != nil {
return nil, err
}
out := make([]*noticev1.NoticeRuleItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &noticev1.NoticeRuleItem{
Id: v.Id, Name: v.Name, EventType: v.EventType, ChannelCodes: v.ChannelCodes,
UserIds: v.UserIds, TitleTemplate: v.TitleTemplate, BodyTemplate: v.BodyTemplate, Enabled: v.Enabled,
})
}
return &noticev1.NoticeRuleListRes{List: out}, nil
}
// RuleSave 保存通知规则。
func (c *Controller) RuleSave(ctx context.Context, req *noticev1.NoticeRuleSaveReq) (res *noticev1.NoticeRuleSaveRes, err error) {
id, err := notice.Notice().RuleSave(ctx, dto.NoticeRuleInput{
Id: req.Id, Name: req.Name, EventType: req.EventType, ChannelCodes: req.ChannelCodes,
UserIds: req.UserIds, TitleTemplate: req.TitleTemplate, BodyTemplate: req.BodyTemplate, Enabled: req.Enabled,
})
if err != nil {
return nil, err
}
return &noticev1.NoticeRuleSaveRes{Id: id}, nil
}
// RuleDelete 删除通知规则。
func (c *Controller) RuleDelete(ctx context.Context, req *noticev1.NoticeRuleDeleteReq) (res *noticev1.NoticeRuleDeleteRes, err error) {
if err = notice.Notice().RuleDelete(ctx, req.Id); err != nil {
return nil, err
}
return &noticev1.NoticeRuleDeleteRes{}, nil
}
// LogList 通知发送记录分页。
func (c *Controller) LogList(ctx context.Context, req *noticev1.NoticeLogListReq) (res *noticev1.NoticeLogListRes, err error) {
list, total, err := notice.Notice().LogList(ctx, dto.NoticeLogFilter{
Page: req.Page, Size: req.Size, EventType: req.EventType,
ChannelCode: req.ChannelCode, Result: req.Result, DateFrom: req.DateFrom, DateTo: req.DateTo,
})
if err != nil {
return nil, err
}
out := make([]*noticev1.NoticeLogItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &noticev1.NoticeLogItem{
Id: v.Id, RuleId: v.RuleId, EventType: v.EventType, ChannelCode: v.ChannelCode,
UserId: v.UserId, Target: v.Target, Title: v.Title, Body: v.Body,
Result: v.Result, Error: v.Error, CreatedAt: v.CreatedAt,
})
}
return &noticev1.NoticeLogListRes{List: out, Total: total}, nil
}
// Test 测试通知发送。
func (c *Controller) Test(ctx context.Context, req *noticev1.NoticeTestReq) (res *noticev1.NoticeTestRes, err error) {
ok, msg, err := notice.Notice().Test(ctx, req.RuleId, req.ChannelCode, req.Target, req.UserIds, req.Title, req.Body)
if err != nil {
return nil, err
}
return &noticev1.NoticeTestRes{Result: ok, Message: msg}, nil
}

51
internal/dao/notice.go Normal file
View File

@ -0,0 +1,51 @@
// Package dao 通知模块与自动任务模块数据访问(主库默认分组,手写)。
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
// noticeModel 主库通用模型构造。
func noticeModel(table string) func(ctx context.Context) *gdb.Model {
return func(ctx context.Context) *gdb.Model {
return g.DB().Model(table).Safe()
}
}
type noticeChannelDao struct{ table string }
// NoticeChannel 通知渠道表访问对象。
var NoticeChannel = noticeChannelDao{table: "notice_channel"}
func (d noticeChannelDao) Ctx(ctx context.Context) *gdb.Model { return noticeModel(d.table)(ctx) }
type noticeRuleDao struct{ table string }
// NoticeRule 通知规则表访问对象。
var NoticeRule = noticeRuleDao{table: "notice_rule"}
func (d noticeRuleDao) Ctx(ctx context.Context) *gdb.Model { return noticeModel(d.table)(ctx) }
type noticeLogDao struct{ table string }
// NoticeLog 通知记录表访问对象。
var NoticeLog = noticeLogDao{table: "notice_log"}
func (d noticeLogDao) Ctx(ctx context.Context) *gdb.Model { return noticeModel(d.table)(ctx) }
type autoJobDao struct{ table string }
// AutoJob 自动任务定义表访问对象。
var AutoJob = autoJobDao{table: "auto_job"}
func (d autoJobDao) Ctx(ctx context.Context) *gdb.Model { return noticeModel(d.table)(ctx) }
type autoJobLogDao struct{ table string }
// AutoJobLog 自动任务运行日志表访问对象。
var AutoJobLog = autoJobLogDao{table: "auto_job_log"}
func (d autoJobLogDao) Ctx(ctx context.Context) *gdb.Model { return noticeModel(d.table)(ctx) }

View File

@ -11,14 +11,16 @@ import (
// AdminUser 是表 admin_user 的 Go 结构体,供 DAO 的 Where/Data 等操作使用。 // AdminUser 是表 admin_user 的 Go 结构体,供 DAO 的 Where/Data 等操作使用。
type AdminUser struct { type AdminUser struct {
g.Meta `orm:"table:admin_user, do:true"` g.Meta `orm:"table:admin_user, do:true"`
Id any // Id any //
Username any // Username any //
PasswordHash any // PasswordHash any //
Nickname any // Nickname any //
Status any // Status any //
LastLoginAt *gtime.Time // BarkDeviceId any //
CreatedAt *gtime.Time // PushplusToken any //
UpdatedAt *gtime.Time // LastLoginAt *gtime.Time //
DeletedAt *gtime.Time // CreatedAt *gtime.Time //
UpdatedAt *gtime.Time //
DeletedAt *gtime.Time //
} }

View File

@ -0,0 +1,78 @@
// Package do 定义通知模块与自动任务模块的数据对象(手写)。
package do
import "github.com/gogf/gf/v2/frame/g"
// NoticeChannel 通知渠道 DO。
type NoticeChannel struct {
g.Meta `orm:"table:notice_channel, do:true"`
Id any //
Code any // 渠道编码
Name any // 渠道名称
Enabled any // 是否启用
Config any // 配置JSON
Remark any // 备注
CreatedAt any //
UpdatedAt any //
}
// NoticeRule 通知规则 DO。
type NoticeRule struct {
g.Meta `orm:"table:notice_rule, do:true"`
Id any //
Name any // 规则名称
EventType any // 事件类型
ChannelCodes any // 渠道JSON数组
UserIds any // 用户JSON数组
TitleTemplate any // 标题模板
BodyTemplate any // 内容模板
Enabled any // 是否启用
CreatedAt any //
UpdatedAt any //
}
// NoticeLog 通知发送记录 DO。
type NoticeLog struct {
g.Meta `orm:"table:notice_log, do:true"`
Id any //
RuleId any // 规则ID
EventType any // 事件类型
ChannelCode any // 渠道编码
UserId any // 用户ID
Target any // 发送目标
Title any // 标题
Body any // 内容
Result any // 结果
Error any // 错误
CreatedAt any //
}
// AutoJob 自动任务 DO。
type AutoJob struct {
g.Meta `orm:"table:auto_job, do:true"`
Id any //
Name any // 任务名称
Code any // 任务编码
JobType any // 任务类型
CronExpr any // cron表达式
Enabled any // 是否启用
Remark any // 备注
LastRunAt any // 上次运行
LastResult any // 上次结果
LastError any // 上次错误
CreatedAt any //
UpdatedAt any //
}
// AutoJobLog 自动任务运行日志 DO。
type AutoJobLog struct {
g.Meta `orm:"table:auto_job_log, do:true"`
Id any //
JobId any // 任务ID
RunAt any // 运行时间
Result any // 结果
Error any // 错误
Summary any // 摘要
DurationMs any // 耗时
CreatedAt any //
}

View File

@ -8,27 +8,33 @@ type PageQuery struct {
} }
type AdminItem struct { type AdminItem struct {
Id uint64 Id uint64
Username string Username string
Nickname string Nickname string
Status int Status int
RoleIds []uint64 BarkDeviceId string
RoleNames []string PushplusToken string
CreatedAt string RoleIds []uint64
RoleNames []string
CreatedAt string
} }
type AdminCreateInput struct { type AdminCreateInput struct {
Username string Username string
Password string Password string
Nickname string Nickname string
RoleIds []uint64 BarkDeviceId string
PushplusToken string
RoleIds []uint64
} }
type AdminUpdateInput struct { type AdminUpdateInput struct {
Id uint64 Id uint64
Nickname string Nickname string
Status int Status int
RoleIds []uint64 BarkDeviceId string
PushplusToken string
RoleIds []uint64
} }
type RoleItem struct { type RoleItem struct {

43
internal/model/dto/job.go Normal file
View File

@ -0,0 +1,43 @@
// Package dto 定义自动任务模块的服务边界对象。
package dto
// JobVO 任务出参。
type JobVO struct {
Id uint64
Name string
Code string
JobType string
CronExpr string
Enabled int
Remark string
LastRunAt string
LastResult int
LastError string
}
// JobInput 任务更新入参cron/启用/备注)。
type JobInput struct {
Id uint64
CronExpr string
Enabled int
Remark string
}
// JobLogVO 任务运行日志出参。
type JobLogVO struct {
Id uint64
JobId uint64
JobName string
RunAt string
Result int
Error string
Summary string
DurationMs int
}
// JobLogFilter 运行日志筛选。
type JobLogFilter struct {
Page int
Size int
JobId uint64
}

View File

@ -0,0 +1,83 @@
// Package dto 定义通知模块的服务边界对象(入参/出参)。
package dto
import "strings"
// NoticeChannelVO 渠道出参。
type NoticeChannelVO struct {
Id uint64
Code string
Name string
Enabled int
Config string
Remark string
}
// NoticeChannelInput 渠道入参。
type NoticeChannelInput struct {
Id uint64
Code string
Name string
Enabled int
Config string
Remark string
}
// NoticeRuleVO 规则出参。
type NoticeRuleVO struct {
Id uint64
Name string
EventType string
ChannelCodes []string
UserIds []uint64
TitleTemplate string
BodyTemplate string
Enabled int
}
// NoticeRuleInput 规则入参。
type NoticeRuleInput struct {
Id uint64
Name string
EventType string
ChannelCodes []string
UserIds []uint64
TitleTemplate string
BodyTemplate string
Enabled int
}
// NoticeLogVO 通知记录出参。
type NoticeLogVO struct {
Id uint64
RuleId uint64
EventType string
ChannelCode string
UserId uint64
Target string
Title string
Body string
Result int
Error string
CreatedAt string
}
// NoticeLogFilter 通知记录筛选。
type NoticeLogFilter struct {
Page int
Size int
EventType string
ChannelCode string
Result int // 0全部 1成功 2失败
DateFrom string
DateTo string
}
// RenderTemplate 简单模板渲染:将 {{key}} 替换为 vars 中对应值。
func RenderTemplate(tpl string, vars map[string]string) string {
out := tpl
for k, v := range vars {
out = strings.ReplaceAll(out, "{{"+k+"}}", v)
}
return out
}

View File

@ -10,13 +10,15 @@ import (
// AdminUser 是表 admin_user 的 Go 结构体。 // AdminUser 是表 admin_user 的 Go 结构体。
type AdminUser struct { type AdminUser struct {
Id uint64 `json:"id" orm:"id" description:""` // Id uint64 `json:"id" orm:"id" description:""` //
Username string `json:"username" orm:"username" description:""` // Username string `json:"username" orm:"username" description:""` //
PasswordHash string `json:"passwordHash" orm:"password_hash" description:""` // PasswordHash string `json:"passwordHash" orm:"password_hash" description:""` //
Nickname string `json:"nickname" orm:"nickname" description:""` // Nickname string `json:"nickname" orm:"nickname" description:""` //
Status int `json:"status" orm:"status" description:""` // Status int `json:"status" orm:"status" description:""` //
LastLoginAt *gtime.Time `json:"lastLoginAt" orm:"last_login_at" description:""` // BarkDeviceId string `json:"barkDeviceId" orm:"bark_device_id" description:"Bark设备ID"` //
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` // PushplusToken string `json:"pushplusToken" orm:"pushplus_token" description:"pushplus token"` //
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` // LastLoginAt *gtime.Time `json:"lastLoginAt" orm:"last_login_at" description:""` //
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` // CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:""` //
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:""` //
DeletedAt *gtime.Time `json:"deletedAt" orm:"deleted_at" description:""` //
} }

View File

@ -0,0 +1,71 @@
// Package entity 定义通知模块与自动任务模块的表结构(手写,未跑 gf gen
package entity
// NoticeChannel 通知渠道配置。
type NoticeChannel struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
Code string `json:"code" orm:"code" description:"渠道编码"`
Name string `json:"name" orm:"name" description:"渠道名称"`
Enabled int `json:"enabled" orm:"enabled" description:"是否启用"`
Config string `json:"config" orm:"config" description:"渠道配置JSON"`
Remark string `json:"remark" orm:"remark" description:"备注"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// NoticeRule 通知规则(事件→渠道→人员→启用→模板)。
type NoticeRule struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
Name string `json:"name" orm:"name" description:"规则名称"`
EventType string `json:"eventType" orm:"event_type" description:"事件类型"`
ChannelCodes string `json:"channelCodes" orm:"channel_codes" description:"渠道编码JSON数组"`
UserIds string `json:"userIds" orm:"user_ids" description:"接收用户ID JSON数组"`
TitleTemplate string `json:"titleTemplate" orm:"title_template" description:"标题模板"`
BodyTemplate string `json:"bodyTemplate" orm:"body_template" description:"内容模板"`
Enabled int `json:"enabled" orm:"enabled" description:"是否启用"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// NoticeLog 通知发送记录。
type NoticeLog struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
RuleId uint64 `json:"ruleId" orm:"rule_id" description:"规则ID"`
EventType string `json:"eventType" orm:"event_type" description:"事件类型"`
ChannelCode string `json:"channelCode" orm:"channel_code" description:"渠道编码"`
UserId uint64 `json:"userId" orm:"user_id" description:"接收用户ID"`
Target string `json:"target" orm:"target" 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:"创建时间"`
}
// AutoJob 自动任务定义。
type AutoJob struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
Name string `json:"name" orm:"name" description:"任务名称"`
Code string `json:"code" orm:"code" description:"任务编码"`
JobType string `json:"jobType" orm:"job_type" description:"任务类型"`
CronExpr string `json:"cronExpr" orm:"cron_expr" description:"cron表达式"`
Enabled int `json:"enabled" orm:"enabled" description:"是否启用"`
Remark string `json:"remark" orm:"remark" description:"备注"`
LastRunAt string `json:"lastRunAt" orm:"last_run_at" description:"上次运行时间"`
LastResult int `json:"lastResult" orm:"last_result" description:"0未运行 1成功 2失败"`
LastError string `json:"lastError" orm:"last_error" description:"上次错误"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
UpdatedAt string `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
// AutoJobLog 自动任务运行日志。
type AutoJobLog struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
JobId uint64 `json:"jobId" orm:"job_id" description:"任务ID"`
RunAt string `json:"runAt" orm:"run_at" description:"运行时间"`
Result int `json:"result" orm:"result" description:"1成功 2失败"`
Error string `json:"error" orm:"error" description:"错误信息"`
Summary string `json:"summary" orm:"summary" description:"运行摘要"`
DurationMs int `json:"durationMs" orm:"duration_ms" description:"耗时毫秒"`
CreatedAt string `json:"createdAt" orm:"created_at" description:"创建时间"`
}

View File

@ -60,6 +60,7 @@ func (s *adminManage) List(ctx context.Context, q dto.PageQuery) ([]*dto.AdminIt
} }
items = append(items, &dto.AdminItem{ items = append(items, &dto.AdminItem{
Id: a.Id, Username: a.Username, Nickname: a.Nickname, Status: a.Status, Id: a.Id, Username: a.Username, Nickname: a.Nickname, Status: a.Status,
BarkDeviceId: a.BarkDeviceId, PushplusToken: a.PushplusToken,
RoleIds: roleIds, RoleNames: roleNames, RoleIds: roleIds, RoleNames: roleNames,
CreatedAt: a.CreatedAt.Layout("2006-01-02 15:04:05"), CreatedAt: a.CreatedAt.Layout("2006-01-02 15:04:05"),
}) })
@ -104,6 +105,7 @@ func (s *adminManage) Create(ctx context.Context, in dto.AdminCreateInput) (uint
} }
id, err := dao.AdminUser.Ctx(ctx).Data(do.AdminUser{ id, err := dao.AdminUser.Ctx(ctx).Data(do.AdminUser{
Username: in.Username, PasswordHash: string(hash), Nickname: in.Nickname, Status: 1, Username: in.Username, PasswordHash: string(hash), Nickname: in.Nickname, Status: 1,
BarkDeviceId: in.BarkDeviceId, PushplusToken: in.PushplusToken,
}).InsertAndGetId() }).InsertAndGetId()
if err != nil { if err != nil {
return 0, gerror.Wrap(err, "insert admin") return 0, gerror.Wrap(err, "insert admin")
@ -115,7 +117,8 @@ func (s *adminManage) Create(ctx context.Context, in dto.AdminCreateInput) (uint
} }
func (s *adminManage) Update(ctx context.Context, in dto.AdminUpdateInput) error { func (s *adminManage) Update(ctx context.Context, in dto.AdminUpdateInput) error {
data := do.AdminUser{Nickname: in.Nickname, Status: in.Status} data := do.AdminUser{Nickname: in.Nickname, Status: in.Status,
BarkDeviceId: in.BarkDeviceId, PushplusToken: in.PushplusToken}
if _, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: in.Id}).Data(data).Update(); err != nil { if _, err := dao.AdminUser.Ctx(ctx).Where(do.AdminUser{Id: in.Id}).Data(data).Update(); err != nil {
return gerror.Wrap(err, "update admin") return gerror.Wrap(err, "update admin")
} }

232
internal/service/job/job.go Normal file
View File

@ -0,0 +1,232 @@
// Package job 提供自动任务调度服务DB 驱动的 gcron 管理 + 运行记录 + 完成/失败通知。
package job
import (
"context"
"fmt"
"sync"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcron"
"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"
"service.xpcool.com/internal/service/notice"
)
// TaskFunc 任务执行函数:返回运行摘要与错误。
type TaskFunc func(context.Context) (string, error)
// IJob 自动任务服务接口。
type IJob interface {
List(context.Context) ([]dto.JobVO, error)
Save(context.Context, dto.JobInput) error
Trigger(context.Context, uint64) (string, bool, error)
LogList(context.Context, dto.JobLogFilter) ([]dto.JobLogVO, int, error)
Register(string, TaskFunc)
StartScheduler(context.Context)
}
type job struct {
mu sync.Mutex
registry map[string]TaskFunc // code -> 业务函数
}
var localJob IJob
// New 创建自动任务服务实现。
func New() IJob {
return &job{registry: make(map[string]TaskFunc)}
}
// Job 返回已注册的自动任务服务实现。
func Job() IJob {
if localJob == nil {
panic("Job implementation not registered")
}
return localJob
}
// RegisterJob 注册自动任务服务实现。
func RegisterJob(i IJob) { localJob = i }
// Register 注册任务执行函数(按 code
func (s *job) Register(code string, fn TaskFunc) {
s.mu.Lock()
defer s.mu.Unlock()
s.registry[code] = fn
}
// ---------------- 列表 / 保存 / 触发 / 日志 ----------------
func (s *job) List(ctx context.Context) ([]dto.JobVO, error) {
var list []entity.AutoJob
if err := dao.AutoJob.Ctx(ctx).OrderAsc("id").Scan(&list); err != nil {
return nil, gerror.Wrap(err, "query auto jobs")
}
out := make([]dto.JobVO, 0, len(list))
for _, v := range list {
out = append(out, dto.JobVO{
Id: v.Id, Name: v.Name, Code: v.Code, JobType: v.JobType, CronExpr: v.CronExpr,
Enabled: v.Enabled, Remark: v.Remark, LastRunAt: v.LastRunAt,
LastResult: v.LastResult, LastError: v.LastError,
})
}
return out, nil
}
func (s *job) Save(ctx context.Context, in dto.JobInput) error {
var j entity.AutoJob
if err := dao.AutoJob.Ctx(ctx).Where("id", in.Id).Scan(&j); err != nil || j.Id == 0 {
return response.Error(consts.CodeInvalidParam, "任务不存在")
}
data := map[string]interface{}{"remark": in.Remark}
if in.CronExpr != "" {
data["cron_expr"] = in.CronExpr
}
if in.Enabled >= 0 {
data["enabled"] = in.Enabled
}
if _, err := dao.AutoJob.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
return gerror.Wrap(err, "update auto job")
}
// 动态重启调度:移除旧 cron按新配置重新注册
s.reschedule(ctx, j.Code)
return nil
}
func (s *job) Trigger(ctx context.Context, id uint64) (string, bool, error) {
var j entity.AutoJob
if err := dao.AutoJob.Ctx(ctx).Where("id", id).Scan(&j); err != nil || j.Id == 0 {
return "", false, response.Error(consts.CodeInvalidParam, "任务不存在")
}
summary, err := s.run(ctx, j)
return summary, err == nil, err
}
func (s *job) LogList(ctx context.Context, f dto.JobLogFilter) ([]dto.JobLogVO, int, error) {
m := dao.AutoJobLog.Ctx(ctx)
if f.JobId > 0 {
m = m.Where("job_id", f.JobId)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "count job log")
}
var list []entity.AutoJobLog
if err = m.Page(f.Page, f.Size).OrderDesc("id").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query job log")
}
out := make([]dto.JobLogVO, 0, len(list))
for _, v := range list {
jobName := ""
if f.JobId == 0 {
var j entity.AutoJob
_ = dao.AutoJob.Ctx(ctx).Where("id", v.JobId).Scan(&j)
jobName = j.Name
}
out = append(out, dto.JobLogVO{
Id: v.Id, JobId: v.JobId, JobName: jobName, RunAt: v.RunAt,
Result: v.Result, Error: v.Error, Summary: v.Summary, DurationMs: v.DurationMs,
})
}
return out, total, nil
}
// ---------------- 调度 ----------------
// StartScheduler 启动调度:加载 DB 中启用的任务并注册 gcron。
func (s *job) StartScheduler(ctx context.Context) {
var jobs []entity.AutoJob
if err := dao.AutoJob.Ctx(ctx).Where("enabled", 1).Scan(&jobs); err != nil {
g.Log().Errorf(ctx, "load auto jobs failed: %v", err)
return
}
for _, j := range jobs {
s.addCron(ctx, j)
}
g.Log().Infof(ctx, "auto job scheduler started: %d jobs", len(jobs))
}
// addCron 为任务注册 gcron不存在的函数跳过
func (s *job) addCron(ctx context.Context, j entity.AutoJob) {
s.mu.Lock()
_, ok := s.registry[j.Code]
s.mu.Unlock()
if !ok {
g.Log().Warningf(ctx, "job code %s has no handler, skip cron", j.Code)
return
}
if _, err := gcron.Add(ctx, j.CronExpr, func(c context.Context) {
_, _ = s.run(c, j)
}, j.Code); err != nil {
g.Log().Errorf(ctx, "add cron %s(%s) failed: %v", j.Code, j.CronExpr, err)
}
}
// reschedule 按 DB 最新状态重启任务的调度(停用则移除 cron
func (s *job) reschedule(ctx context.Context, code string) {
gcron.Remove(code)
var j entity.AutoJob
if err := dao.AutoJob.Ctx(ctx).Where("code", code).Scan(&j); err != nil || j.Id == 0 {
return
}
if j.Enabled == 1 {
s.addCron(ctx, j)
}
}
// run 执行任务:调用注册函数 → 更新任务状态 → 写运行日志 → 通知完成/失败。
func (s *job) run(ctx context.Context, j entity.AutoJob) (string, error) {
s.mu.Lock()
fn, ok := s.registry[j.Code]
s.mu.Unlock()
if !ok {
err := fmt.Errorf("no handler for job %s", j.Code)
s.finish(ctx, j, 2, err.Error(), "", 0)
return "", err
}
start := time.Now()
summary, err := fn(ctx)
duration := int(time.Since(start).Milliseconds())
if err != nil {
s.finish(ctx, j, 2, err.Error(), summary, duration)
return summary, err
}
s.finish(ctx, j, 1, "", summary, duration)
return summary, nil
}
// finish 更新任务状态 + 写运行日志 + 发通知。
func (s *job) finish(ctx context.Context, j entity.AutoJob, result int, errMsg, summary string, duration int) {
// 更新任务状态
if _, err := dao.AutoJob.Ctx(ctx).Where("id", j.Id).Data(map[string]interface{}{
"last_run_at": gtime.Now().Format("Y-m-d H:i:s"),
"last_result": result, "last_error": errMsg,
}).Update(); err != nil {
g.Log().Errorf(ctx, "update job %s status failed: %v", j.Code, err)
}
// 写运行日志
if _, err := dao.AutoJobLog.Ctx(ctx).Data(map[string]interface{}{
"job_id": j.Id, "result": result, "error": errMsg,
"summary": summary, "duration_ms": duration,
}).Insert(); err != nil {
g.Log().Errorf(ctx, "insert job log failed: %v", err)
}
// 通知:完成/失败
vars := map[string]string{"jobName": j.Name, "summary": summary, "error": errMsg}
if result == 1 {
if summary == "" {
summary = "执行完成"
}
_ = notice.Notice().Send(ctx, "job_done", vars)
} else {
_ = notice.Notice().Send(ctx, "job_fail", vars)
}
}

View File

@ -0,0 +1,352 @@
// Package notice 提供统一通知服务(渠道/规则/发送/日志)。
// 发送实现Bark 走路径式 keyPOST {baseUrl}/{deviceKey} JSONpushplus 走官方 send 接口。
package notice
import (
"context"
"encoding/json"
"strings"
"time"
"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"
)
// INotice 统一通知服务接口。
type INotice interface {
ChannelList(context.Context) ([]dto.NoticeChannelVO, error)
ChannelSave(context.Context, dto.NoticeChannelInput) (uint64, error)
RuleList(context.Context, string) ([]dto.NoticeRuleVO, error)
RuleSave(context.Context, dto.NoticeRuleInput) (uint64, error)
RuleDelete(context.Context, uint64) error
LogList(context.Context, dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error)
Send(context.Context, string, map[string]string) error // 按启用规则发送通知(事件→渠道→人员)
Test(context.Context, uint64, string, string, []uint64, string, string) (bool, string, error)
}
type notice struct{}
var localNotice INotice
// New 创建通知服务实现。
func New() INotice { return &notice{} }
// Notice 返回已注册的通知服务实现。
func Notice() INotice {
if localNotice == nil {
panic("Notice implementation not registered")
}
return localNotice
}
// RegisterNotice 注册通知服务实现。
func RegisterNotice(i INotice) { localNotice = i }
// ---------------- 渠道 ----------------
func (s *notice) ChannelList(ctx context.Context) ([]dto.NoticeChannelVO, error) {
var list []entity.NoticeChannel
if err := dao.NoticeChannel.Ctx(ctx).OrderAsc("id").Scan(&list); err != nil {
return nil, gerror.Wrap(err, "query notice channels")
}
out := make([]dto.NoticeChannelVO, 0, len(list))
for _, v := range list {
out = append(out, dto.NoticeChannelVO{Id: v.Id, Code: v.Code, Name: v.Name, Enabled: v.Enabled, Config: v.Config, Remark: v.Remark})
}
return out, nil
}
func (s *notice) ChannelSave(ctx context.Context, in dto.NoticeChannelInput) (uint64, error) {
data := map[string]interface{}{
"code": in.Code, "name": in.Name, "enabled": in.Enabled,
"config": in.Config, "remark": in.Remark,
}
if in.Id > 0 {
if _, err := dao.NoticeChannel.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
return 0, gerror.Wrap(err, "update notice channel")
}
return in.Id, nil
}
id, err := dao.NoticeChannel.Ctx(ctx).Data(data).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert notice channel")
}
return uint64(id), nil
}
// ---------------- 规则 ----------------
func (s *notice) RuleList(ctx context.Context, eventType string) ([]dto.NoticeRuleVO, error) {
m := dao.NoticeRule.Ctx(ctx)
if eventType != "" {
m = m.Where("event_type", eventType)
}
var list []entity.NoticeRule
if err := m.OrderAsc("id").Scan(&list); err != nil {
return nil, gerror.Wrap(err, "query notice rules")
}
out := make([]dto.NoticeRuleVO, 0, len(list))
for _, v := range list {
out = append(out, dto.NoticeRuleVO{
Id: v.Id, Name: v.Name, EventType: v.EventType,
ChannelCodes: parseStrArray(v.ChannelCodes), UserIds: parseUintArray(v.UserIds),
TitleTemplate: v.TitleTemplate, BodyTemplate: v.BodyTemplate, Enabled: v.Enabled,
})
}
return out, nil
}
func (s *notice) RuleSave(ctx context.Context, in dto.NoticeRuleInput) (uint64, error) {
data := map[string]interface{}{
"name": in.Name, "event_type": in.EventType,
"channel_codes": toJSON(in.ChannelCodes), "user_ids": toJSON(in.UserIds),
"title_template": in.TitleTemplate, "body_template": in.BodyTemplate, "enabled": in.Enabled,
}
if in.Id > 0 {
if _, err := dao.NoticeRule.Ctx(ctx).Where("id", in.Id).Data(data).Update(); err != nil {
return 0, gerror.Wrap(err, "update notice rule")
}
return in.Id, nil
}
id, err := dao.NoticeRule.Ctx(ctx).Data(data).InsertAndGetId()
if err != nil {
return 0, gerror.Wrap(err, "insert notice rule")
}
return uint64(id), nil
}
func (s *notice) RuleDelete(ctx context.Context, id uint64) error {
if _, err := dao.NoticeRule.Ctx(ctx).Where("id", id).Delete(); err != nil {
return gerror.Wrap(err, "delete notice rule")
}
return nil
}
// ---------------- 日志 ----------------
func (s *notice) LogList(ctx context.Context, f dto.NoticeLogFilter) ([]dto.NoticeLogVO, int, error) {
m := dao.NoticeLog.Ctx(ctx)
if f.EventType != "" {
m = m.Where("event_type", f.EventType)
}
if f.ChannelCode != "" {
m = m.Where("channel_code", f.ChannelCode)
}
if f.Result > 0 {
m = m.Where("result", f.Result)
}
if f.DateFrom != "" {
m = m.WhereGTE("created_at", f.DateFrom)
}
if f.DateTo != "" {
m = m.WhereLTE("created_at", f.DateTo)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "count notice log")
}
var list []entity.NoticeLog
if err = m.Page(f.Page, f.Size).OrderDesc("id").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query notice log")
}
out := make([]dto.NoticeLogVO, 0, len(list))
for _, v := range list {
out = append(out, dto.NoticeLogVO{
Id: v.Id, RuleId: v.RuleId, EventType: v.EventType, ChannelCode: v.ChannelCode,
UserId: v.UserId, Target: v.Target, Title: v.Title, Body: v.Body,
Result: v.Result, Error: v.Error, CreatedAt: v.CreatedAt,
})
}
return out, total, nil
}
// ---------------- 发送 ----------------
// Send 按事件类型查启用规则,向每个渠道的每个接收用户发送,并记录日志。
func (s *notice) Send(ctx context.Context, eventType string, vars map[string]string) error {
var rules []entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("event_type", eventType).Where("enabled", 1).Scan(&rules); err != nil {
return gerror.Wrap(err, "query notice rules for send")
}
if len(rules) == 0 {
return nil // 未配置规则 = 静默
}
for _, r := range rules {
channels := parseStrArray(r.ChannelCodes)
userIds := parseUintArray(r.UserIds)
title := dto.RenderTemplate(r.TitleTemplate, vars)
body := dto.RenderTemplate(r.BodyTemplate, vars)
for _, channel := range channels {
for _, uid := range userIds {
s.deliver(ctx, r.Id, eventType, channel, uid, title, body)
}
}
}
return nil
}
// deliver 单条投递:取用户渠道凭据 → 发送 → 记日志。
func (s *notice) deliver(ctx context.Context, ruleId uint64, eventType, channel string, userId uint64, title, body string) {
var user entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", userId).Scan(&user)
var target string
switch channel {
case "bark":
target = user.BarkDeviceId
case "pushplus":
target = user.PushplusToken
default:
target = ""
}
ok, errMsg := s.sendByChannel(ctx, channel, target, title, body)
result := 0
if ok {
result = 1
}
_, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
"rule_id": ruleId, "event_type": eventType, "channel_code": channel,
"user_id": userId, "target": target, "title": title, "body": body,
"result": result, "error": errMsg,
}).Insert()
}
// sendByChannel 按渠道发送单条;返回 (是否成功, 错误信息)。
func (s *notice) sendByChannel(ctx context.Context, channel, target, title, body string) (bool, string) {
if target == "" {
return false, "目标凭据为空(用户未配置)"
}
client := g.Client().SetTimeout(10 * time.Second)
switch channel {
case "bark":
// 渠道配置取 baseUrlJSON config.baseUrl默认容器网络内 bark:8080
baseURL := "http://bark:8080"
var ch entity.NoticeChannel
if err := dao.NoticeChannel.Ctx(ctx).Where("code", "bark").Scan(&ch); err == nil && ch.Config != "" {
var cfg struct {
BaseURL string `json:"baseUrl"`
}
_ = json.Unmarshal([]byte(ch.Config), &cfg)
if cfg.BaseURL != "" {
baseURL = cfg.BaseURL
}
}
// 路径式 keyPOST {baseUrl}/{deviceKey}body JSON {title, body}
url := strings.TrimRight(baseURL, "/") + "/" + target
resp, err := client.Post(ctx, url, map[string]string{"title": title, "body": body})
if err != nil {
return false, err.Error()
}
if resp.StatusCode != 200 {
return false, "bark http " + resp.Status
}
var out struct {
Code int `json:"code"`
}
_ = json.Unmarshal(resp.ReadAll(), &out)
if out.Code != 200 {
return false, "bark code " + resp.ReadAllString()
}
return true, ""
case "pushplus":
// 官方接口POST https://www.pushplus.plus/send {token,title,content}
resp, err := client.Post(ctx, "https://www.pushplus.plus/send", map[string]string{
"token": target, "title": title, "content": body,
})
if err != nil {
return false, err.Error()
}
if resp.StatusCode != 200 {
return false, "pushplus http " + resp.Status
}
var out struct {
Code int `json:"code"`
}
_ = json.Unmarshal(resp.ReadAll(), &out)
if out.Code != 200 {
return false, "pushplus code " + resp.ReadAllString()
}
return true, ""
}
return false, "未知渠道: " + channel
}
// Test 测试发送:优先按规则;否则手动指定渠道+目标。
func (s *notice) Test(ctx context.Context, ruleId uint64, channelCode, target string, userIds []uint64, title, body string) (bool, string, error) {
if ruleId > 0 {
var r entity.NoticeRule
if err := dao.NoticeRule.Ctx(ctx).Where("id", ruleId).Scan(&r); err != nil || r.Id == 0 {
return false, "", response.Error(consts.CodeInvalidParam, "规则不存在")
}
for _, ch := range parseStrArray(r.ChannelCodes) {
for _, uid := range parseUintArray(r.UserIds) {
s.deliver(ctx, r.Id, r.EventType, ch, uid, title, body)
}
}
return true, "已按规则投递", nil
}
// 手动:渠道+目标target 为空则按用户)
if channelCode == "" {
return false, "", response.Error(consts.CodeInvalidParam, "请指定渠道")
}
if target == "" && len(userIds) > 0 {
var user entity.AdminUser
_ = dao.AdminUser.Ctx(ctx).Where("id", userIds[0]).Scan(&user)
if channelCode == "bark" {
target = user.BarkDeviceId
} else if channelCode == "pushplus" {
target = user.PushplusToken
}
}
if target == "" {
return false, "", response.Error(consts.CodeInvalidParam, "目标凭据为空")
}
ok, msg := s.sendByChannel(ctx, channelCode, target, title, body)
_, _ = dao.NoticeLog.Ctx(ctx).Data(map[string]interface{}{
"rule_id": 0, "event_type": "test", "channel_code": channelCode,
"user_id": 0, "target": target, "title": title, "body": body,
"result": boolToInt(ok), "error": msg,
}).Insert()
return ok, msg, nil
}
// ---------------- 工具 ----------------
func parseStrArray(s string) []string {
var out []string
_ = json.Unmarshal([]byte(s), &out)
if out == nil {
out = []string{}
}
return out
}
func parseUintArray(s string) []uint64 {
var out []uint64
_ = json.Unmarshal([]byte(s), &out)
if out == nil {
out = []uint64{}
}
return out
}
func toJSON(v interface{}) string {
b, _ := json.Marshal(v)
return string(b)
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// NowStr 当前时间字符串(供日志)。
func NowStr() string { return gtime.Now().Format("Y-m-d H:i:s") }

View File

@ -3,27 +3,24 @@ package recruitment
import ( import (
"context" "context"
"github.com/gogf/gf/v2/frame/g" "service.xpcool.com/internal/service/job"
"github.com/gogf/gf/v2/os/gcron"
) )
// StartScheduler 注册定时任务:每日 03:00 增量抓取全部启用源08:00 推送招聘早报。 // RegisterTasks 把招聘模块的定时任务注册到自动任务调度器(由 job 模块统一纳管)。
// 调度器随 service 进程常驻,不额外引入系统 crontab。 // 原 StartScheduler 直接注册 gcron 的方式已废弃:任务定义存 auto_job 表016 SQL 种子),
// 注意gcron 表达式为 6 段式(秒 分 时 日 月 周5 段标准 cron 会注册失败。 // 由 job.StartScheduler 按 DB 配置统一调度,支持启停/改 cron/运行日志/完成通知。
func StartScheduler(ctx context.Context) { func RegisterTasks() {
if _, err := gcron.Add(ctx, "0 0 3 * * *", func(c context.Context) { // 每日 03:00 增量抓取全部启用源
_, summary, e := Recruitment().Trigger(c, 0, false) job.Job().Register("recruit-crawl-daily", func(ctx context.Context) (string, error) {
_, summary, e := Recruitment().Trigger(ctx, 0, false)
if e != nil { if e != nil {
g.Log().Errorf(c, "recruit daily crawl failed: %v", e) return summary, e
} else {
g.Log().Infof(c, "recruit daily crawl done: %s", summary)
} }
}, "recruit-crawl-daily"); err != nil { return summary, nil
g.Log().Errorf(ctx, "add recruit crawl cron failed: %v", err) })
} // 每日 08:00 推送招聘早报(订阅列表)
if _, err := gcron.Add(ctx, "0 0 8 * * *", func(c context.Context) { job.Job().Register("recruit-push-daily", func(ctx context.Context) (string, error) {
sendDailyDigest(c) sendDailyDigest(ctx)
}, "recruit-push-daily"); err != nil { return "招聘早报推送完成", nil
g.Log().Errorf(ctx, "add recruit push cron failed: %v", err) })
}
} }

View File

@ -0,0 +1,152 @@
-- 016_user_notice_job.sql
-- 用户管理扩展 + 通知模块 + 自动任务模块建表 + 菜单 + 种子
-- 幂等ALTER TABLE ADD COLUMN IF NOT EXISTSMySQL 8 不支持 COLUMN IF NOT EXISTS用存储过程判断
-- 菜单用 INSERT ... ON DUPLICATE KEY UPDATE种子用 INSERT IGNORE
-- 依赖service 主库admin_menu/admin_role_menu/admin_user 已存在
-- ============ 1) 用户表扩展Bark 设备ID / pushplus token ============
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_user' AND COLUMN_NAME='bark_device_id');
SET @sql := IF(@has_col=0, 'ALTER TABLE admin_user ADD COLUMN bark_device_id VARCHAR(200) NOT NULL DEFAULT '''' COMMENT ''Bark设备ID'' AFTER status', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @has_col := (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_user' AND COLUMN_NAME='pushplus_token');
SET @sql := IF(@has_col=0, 'ALTER TABLE admin_user ADD COLUMN pushplus_token VARCHAR(200) NOT NULL DEFAULT '''' COMMENT ''pushplus token'' AFTER bark_device_id', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- ============ 2) 通知渠道表 ============
CREATE TABLE IF NOT EXISTS `notice_channel` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '渠道编码 bark/pushplus/webhook',
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '渠道名称',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用 0 1',
`config` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '渠道配置JSON( bark baseUrl)',
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知渠道配置';
-- ============ 3) 通知规则表 ============
CREATE TABLE IF NOT EXISTS `notice_rule` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '规则名称',
`event_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '事件类型 job_done/job_fail/security_alert/recruit_daily',
`channel_codes` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '渠道编码 JSON 数组 ["bark","pushplus"]',
`user_ids` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '接收用户ID JSON 数组 [1,2]',
`title_template` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题模板(支持 {var} 占位符)',
`body_template` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '内容模板',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用 0 1',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_event` (`event_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知规则(事件渠道人员启用)';
-- ============ 4) 通知发送记录 ============
CREATE TABLE IF NOT EXISTS `notice_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`rule_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '规则ID',
`event_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '事件类型',
`channel_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '渠道编码',
`user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '接收用户ID',
`target` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '发送目标(设备key/token)',
`title` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '标题',
`body` VARCHAR(2000) NOT NULL DEFAULT '' COMMENT '内容',
`result` TINYINT NOT NULL DEFAULT 0 COMMENT '1成功 0失败',
`error` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '错误信息',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_event` (`event_type`),
KEY `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知发送记录';
-- ============ 5) 自动任务定义 ============
CREATE TABLE IF NOT EXISTS `auto_job` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '任务名称',
`code` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '任务编码(唯一,映射业务函数)',
`job_type` VARCHAR(32) NOT NULL DEFAULT 'gcron' COMMENT '任务类型',
`cron_expr` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'cron 表达式(6: )',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用 0 1',
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`last_run_at` DATETIME NULL DEFAULT NULL COMMENT '上次运行时间',
`last_result` TINYINT NOT NULL DEFAULT 0 COMMENT '0未运行 1成功 2失败',
`last_error` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '上次错误',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自动任务定义';
-- ============ 6) 自动任务运行日志 ============
CREATE TABLE IF NOT EXISTS `auto_job_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`job_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '任务ID',
`run_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '运行时间',
`result` TINYINT NOT NULL DEFAULT 0 COMMENT '1成功 2失败',
`error` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '错误信息',
`summary` VARCHAR(1000) NOT NULL DEFAULT '' COMMENT '运行摘要',
`duration_ms` INT NOT NULL DEFAULT 0 COMMENT '耗时(毫秒)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_job` (`job_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自动任务运行日志';
-- ============ 7) 种子通知渠道 ============
INSERT INTO notice_channel (id, code, name, enabled, config, remark) VALUES
(1, 'bark', 'Bark(iOS)', 1, '{"baseUrl":"http://bark:8080"}', 'Bark 自建服务容器网络内访问 bark:8080'),
(2, 'pushplus', 'pushplus', 1, '{}', '微信推送token 在用户资料中维护')
ON DUPLICATE KEY UPDATE name=VALUES(name), enabled=VALUES(enabled), config=VALUES(config), remark=VALUES(remark);
-- ============ 8) 种子通知规则 ============
INSERT INTO notice_rule (id, name, event_type, channel_codes, user_ids, title_template, body_template, enabled) VALUES
(1, '安全告警', 'security_alert', '["bark"]', '[1]', '服务器安全告警', '{{summary}}', 1),
(2, '任务完成', 'job_done', '["bark"]', '[1]', '任务完成: {{jobName}}', '{{summary}}', 1),
(3, '任务失败', 'job_fail', '["bark","pushplus"]', '[1]', ' 任务失败: {{jobName}}', '{{summary}}', 1)
ON DUPLICATE KEY UPDATE name=VALUES(name), event_type=VALUES(event_type), channel_codes=VALUES(channel_codes),
user_ids=VALUES(user_ids), title_template=VALUES(title_template), body_template=VALUES(body_template), enabled=VALUES(enabled);
-- ============ 9) 种子自动任务现有 gcron 任务纳管 ============
INSERT INTO auto_job (id, name, code, job_type, cron_expr, enabled, remark) VALUES
(1, '招聘公告每日抓取', 'recruit-crawl-daily', 'gcron', '0 0 3 * * *', 1, '每日03:00 增量抓取招聘公告'),
(2, '招聘公告每日推送', 'recruit-push-daily', 'gcron', '0 0 8 * * *', 1, '每日08:00 推送订阅公告( push_subscription 配置)')
ON DUPLICATE KEY UPDATE name=VALUES(name), cron_expr=VALUES(cron_expr), enabled=VALUES(enabled), remark=VALUES(remark);
-- ============ 10) 菜单人员管理改名 用户管理 ============
UPDATE admin_menu SET name='用户管理' WHERE name='人员管理' AND type=1;
-- 补充兜底若没有名为人员管理的菜单如叫管理员管理 component='system/admin/index' 的改名为用户管理
UPDATE admin_menu SET name='用户管理' WHERE component='system/admin/index' AND type=1 AND name != '用户管理';
-- ============ 11) 菜单通知中心 + 自动任务 ============
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(98, 0, '通知中心', 'mdi:bell-ring-outline', 1, '/notice', '', 'notice:center', 8, 1, 0, NOW(), NOW()),
(980, 98, '通知规则', 'mdi:bell-cog-outline', 1, 'rule', 'system/notice/rule/index', 'notice:rule', 1, 1, 0, NOW(), NOW()),
(981, 98, '通知渠道', 'mdi:router-wireless', 1, 'channel', 'system/notice/channel/index', 'notice:channel', 2, 1, 0, NOW(), NOW()),
(982, 98, '通知日志', 'mdi:history', 1, 'log', 'system/notice/log/index', 'notice:log', 3, 1, 0, NOW(), NOW()),
(99, 0, '自动任务', 'mdi:clock-outline', 1, '/auto-job','', 'job:center', 9, 1, 0, NOW(), NOW()),
(990, 99, '任务管理', 'mdi:calendar-clock', 1, 'list', 'system/auto-job/list/index', 'job:list', 1, 1, 0, NOW(), NOW()),
(991, 99, '任务日志', 'mdi:file-clock-outline', 1, 'log', 'system/auto-job/log/index', 'job:log', 2, 1, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), name=VALUES(name), icon=VALUES(icon), type=VALUES(type),
path=VALUES(path), component=VALUES(component), permission=VALUES(permission), sort=VALUES(sort),
status=VALUES(status), hidden=VALUES(hidden), deleted_at=NULL;
-- ============ 12) type=2 API 权限 ============
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(9801, 980, '查询', '', 2, 'POST /api/service/admin/notice/rule/list', '', 'notice:rule:list', 1, 1, 0, NOW(), NOW()),
(9802, 980, '保存', '', 2, 'POST /api/service/admin/notice/rule/save', '', 'notice:rule:save', 2, 1, 0, NOW(), NOW()),
(9803, 980, '删除', '', 2, 'POST /api/service/admin/notice/rule/delete', '', 'notice:rule:delete', 3, 1, 0, NOW(), NOW()),
(9804, 980, '测试', '', 2, 'POST /api/service/admin/notice/test', '', 'notice:test', 4, 1, 0, NOW(), NOW()),
(9811, 981, '查询', '', 2, 'POST /api/service/admin/notice/channel/list', '', 'notice:channel:list', 1, 1, 0, NOW(), NOW()),
(9812, 981, '保存', '', 2, 'POST /api/service/admin/notice/channel/save', '', 'notice:channel:save', 2, 1, 0, NOW(), NOW()),
(9821, 982, '查询', '', 2, 'POST /api/service/admin/notice/log/list', '', 'notice:log:list', 1, 1, 0, NOW(), NOW()),
(9901, 990, '查询', '', 2, 'POST /api/service/admin/auto-job/list', '', 'job:list:query', 1, 1, 0, NOW(), NOW()),
(9902, 990, '保存', '', 2, 'POST /api/service/admin/auto-job/save', '', 'job:list:save', 2, 1, 0, NOW(), NOW()),
(9903, 990, '触发', '', 2, 'POST /api/service/admin/auto-job/trigger', '', 'job:list:trigger', 3, 1, 0, NOW(), NOW()),
(9911, 991, '查询', '', 2, 'POST /api/service/admin/auto-job/log/list', '', 'job:log:list', 1, 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;
-- ============ 13) 超管角色绑定 ============
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 (98,980,981,982,99,990,991,9801,9802,9803,9804,9811,9812,9821,9901,9902,9903,9911);