From 0569c70690ab90085aa0c30d299390c9a230cef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=8F=E7=8A=80=E9=BA=9F?= Date: Thu, 27 Aug 2026 22:58:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(notice+job):=20=E9=80=9A=E7=9F=A5=E6=A8=A1?= =?UTF-8?q?=E5=9D=97(=E6=B8=A0=E9=81=93/=E8=A7=84=E5=88=99/=E6=97=A5?= =?UTF-8?q?=E5=BF=97/=E6=B5=8B=E8=AF=95)=20+=20=E8=87=AA=E5=8A=A8=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=AE=A1=E7=90=86(DB=E9=A9=B1=E5=8A=A8gcron)=20+=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E8=A1=A8=E5=8A=A0Bark/pushplus=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy/memory/CHANGELOG.md | 2 + api/job/v1/job.go | 73 ++++ api/notice/v1/notice.go | 128 +++++++ internal/cmd/cmd.go | 72 ++-- internal/controller/admin/admin.go | 6 +- internal/controller/job/job.go | 68 ++++ internal/controller/notice/notice.go | 108 ++++++ internal/dao/notice.go | 51 +++ internal/model/do/admin_user.go | 22 +- internal/model/do/notice.go | 78 +++++ internal/model/dto/admin_rbac.go | 36 +- internal/model/dto/job.go | 43 +++ internal/model/dto/notice.go | 83 +++++ internal/model/entity/admin_user.go | 20 +- internal/model/entity/notice.go | 71 ++++ internal/service/admin/admin/admin/admin.go | 5 +- internal/service/job/job.go | 232 +++++++++++++ internal/service/notice/notice.go | 352 ++++++++++++++++++++ internal/service/recruitment/scheduler.go | 35 +- manifest/sql/016_user_notice_job.sql | 152 +++++++++ 20 files changed, 1559 insertions(+), 78 deletions(-) create mode 100644 api/job/v1/job.go create mode 100644 api/notice/v1/notice.go create mode 100644 internal/controller/job/job.go create mode 100644 internal/controller/notice/notice.go create mode 100644 internal/dao/notice.go create mode 100644 internal/model/do/notice.go create mode 100644 internal/model/dto/job.go create mode 100644 internal/model/dto/notice.go create mode 100644 internal/model/entity/notice.go create mode 100644 internal/service/job/job.go create mode 100644 internal/service/notice/notice.go create mode 100644 manifest/sql/016_user_notice_job.sql diff --git a/.workbuddy/memory/CHANGELOG.md b/.workbuddy/memory/CHANGELOG.md index 3340631..e963c1b 100644 --- a/.workbuddy/memory/CHANGELOG.md +++ b/.workbuddy/memory/CHANGELOG.md @@ -1,6 +1,8 @@ # service.xpcool.com 变更记录 > 倒序:最新在上。格式:YYYY-MM-DD | 类型 | 摘要 +2026-08-27 | FIX | 菜单种子主键冲突修复(743683e):recruitment/003_menu.sql 招聘中心一级菜单 id=95 与 014_house_presale_menu.sql 新房预售 id=95(挂看房中心 90)ON DUPLICATE 互相覆盖、后导者赢,本地实际发生(新房预售菜单被顶掉致 /house/presale 404、生产库同样风险);改为 id=96 后发现 015_server_security.sql(并行会话,生产已部署)也用 96=安全日志(挂系统监控5),再改 **97**:95=新房预售(挂90)、96=安全日志(挂5)、97=招聘中心,三模块错开;本地按 014→015→003 重放,routes 接口 22 节点验证全部正确(e18b25f)。⚠️ 生产库需按 014→015→003 顺序重放修复(当前生产 95=招聘中心、新房预售菜单丢失) + 2026-08-27 | CHG | 服务器安全日志模块:015 SQL 建 server_security_log 表 + 菜单(96安全日志/960查询/961统计);api/serversecurity/v1 三接口全 POST——open 上报 /api/service/open/security/log/report(body token+list,校验 internalToken=INTERNAL_TOKEN env)+ admin 查询 /server-security/log/list、统计 /server-security/log/stats(时间/IP/类型/端口筛选+TOP攻击源);entity/do/dao(主库)/dto/service/controller(ReportController+ManageController 双控制器);cmd.go 注入 INTERNAL_TOKEN→internalToken、open 组绑 NewReport、protected 组绑 NewManage;go build 通过,交叉编译上传 + 服务器 docker build + 容器重建(复用 env 追加 INTERNAL_TOKEN),路由/上报验证 OK(错误 token 返回 10002) 2026-08-27 | FIX | 本地登录 500 修复:根因=config.dev.yaml 的 ${RECRUITMENT_DB_DSN} 占位符在本地无对应 env(injectEnv 只回填已有变量),gdb 配置解析失败致 g.DB() 全局报 invalid link configuration;本地补建 recruitment 库(导入 001_init+002_seed_sources,6 表 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/service(POST /api/service/admin/house/presale/list,筛选 region/purpose/keyword,id 倒序);dto 加 HousePresaleVO;house_presale 加 publish_date 字段(013 SQL 同步 + hack/config.yaml tables 补 house_presale + gen dao 更新);权限种子 014(菜单 95 新房预售 + 950 查询权限);go build 通过、接口返回 487 条干净数据(presaleNo/publishDate 正确) diff --git a/api/job/v1/job.go b/api/job/v1/job.go new file mode 100644 index 0000000..43553b4 --- /dev/null +++ b/api/job/v1/job.go @@ -0,0 +1,73 @@ +// Package job_v1 自动任务管理模块接口契约(DB 驱动的 gcron 调度 + 运行记录)。 +// 规范(2026-08-27):全部 POST;URL 不含参数;入参一律 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"` +} diff --git a/api/notice/v1/notice.go b/api/notice/v1/notice.go new file mode 100644 index 0000000..a64e062 --- /dev/null +++ b/api/notice/v1/notice.go @@ -0,0 +1,128 @@ +// Package notice_v1 通知模块接口契约(统一推送管理:渠道/规则/日志/测试)。 +// 规范(2026-08-27):全部 POST;URL 不含参数;入参一律 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"` +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index fdac617..6d73b4d 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -4,15 +4,19 @@ import ( "context" "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/os/gcfg" "github.com/gogf/gf/v2/os/gcmd" "github.com/gogf/gf/v2/os/genv" + "github.com/gogf/gf/v2/text/gstr" adminctl "service.xpcool.com/internal/controller/admin" housectl "service.xpcool.com/internal/controller/house" openctl "service.xpcool.com/internal/controller/open" 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" userctl "service.xpcool.com/internal/controller/user" "service.xpcool.com/internal/library/jwt" @@ -32,40 +36,59 @@ import ( housetransaction "service.xpcool.com/internal/service/house/transaction" housepresale "service.xpcool.com/internal/service/house/presale" 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" ) +// 开发环境默认值:任何启动方式(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 手动把关键环境变量写入配置系统。 // 注意:gf v2.10.2 起配置不再自动替换 ${ENV} 占位符,需在此显式注入, // 否则 config.dev.yaml 中的 ${DB_DSN}/${JWT_SECRET} 会原样传给数据库与 JWT。 +// 开发环境下若 env 缺失,回填本地默认值,保证「零配置可启动」。 func injectEnv(ctx context.Context) { adapter, ok := g.Cfg().GetAdapter().(*gcfg.AdapterFile) if !ok { return } - if v := genv.Get("DB_DSN"); !v.IsEmpty() { - _ = adapter.Set("database.default.link", v.String()) - } - if v := genv.Get("JWT_SECRET"); !v.IsEmpty() { - _ = adapter.Set("jwt.secret", v.String()) + isProd := genv.Get("GF_GCFG_ENV").String() == "prod" + if !isProd { + // 开发兜底:仅当配置项仍是未解析的 ${...} 占位符时才写默认值, + // 不覆盖 .env.dev / GoLand 运行配置注入的真实值。 + 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 服务)。 - if v := genv.Get("RECRUITMENT_DB_DSN"); !v.IsEmpty() { - _ = adapter.Set("database.recruitment.link", v.String()) - } - if v := genv.Get("BARK_BASE_URL"); !v.IsEmpty() { - _ = 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()) - } + setIfEmpty(adapter, "database.recruitment.link", "RECRUITMENT_DB_DSN") + setIfEmpty(adapter, "bark.baseUrl", "BARK_BASE_URL") + setIfEmpty(adapter, "bark.deviceKey", "BARK_DEVICE_KEY") + setIfEmpty(adapter, "bark.pushTime", "BARK_PUSH_TIME") // 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。 - if v := genv.Get("INTERNAL_TOKEN"); !v.IsEmpty() { - _ = adapter.Set("internalToken", v.String()) - } + setIfEmpty(adapter, "internalToken", "INTERNAL_TOKEN") } var ( @@ -92,6 +115,8 @@ var ( housetransaction.RegisterTransaction(housetransaction.NewTransaction()) housepresale.RegisterPresale(housepresale.NewPresale()) recruitmentsvc.RegisterRecruitment(recruitmentsvc.New()) + noticesvc.RegisterNotice(noticesvc.New()) + jobsvc.RegisterJob(jobsvc.New()) serversecuritysvc.RegisterServerSecurity(serversecuritysvc.New()) s.Group("/api/service/open", func(group *ghttp.RouterGroup) { group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse) @@ -120,11 +145,14 @@ var ( protected.Bind(adminctl.New()) protected.Bind(housectl.New()) protected.Bind(recruitmentctl.New()) + protected.Bind(noticectl.New()) + protected.Bind(jobctl.New()) protected.Bind(serversecurityctl.NewManage()) }) }) - // 启动招聘模块定时任务(每日增量抓取 + 早报推送)。 - recruitmentsvc.StartScheduler(ctx) + // 启动自动任务调度器:招聘模块先把任务注册进来,再由 job 模块按 DB 配置统一调度。 + recruitmentsvc.RegisterTasks() + jobsvc.Job().StartScheduler(ctx) s.Run() return nil }, diff --git a/internal/controller/admin/admin.go b/internal/controller/admin/admin.go index fc5c762..aeb6ff2 100644 --- a/internal/controller/admin/admin.go +++ b/internal/controller/admin/admin.go @@ -27,7 +27,8 @@ func (c *Controller) AdminList(ctx context.Context, req *adminv1.AdminListReq) ( // AdminCreate 创建管理员。 func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateReq) (res *adminv1.AdminCreateRes, err error) { 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 { return nil, err @@ -37,7 +38,8 @@ func (c *Controller) AdminCreate(ctx context.Context, req *adminv1.AdminCreateRe // AdminUpdate 更新管理员。 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 &adminv1.AdminUpdateRes{}, nil diff --git a/internal/controller/job/job.go b/internal/controller/job/job.go new file mode 100644 index 0000000..b7abd16 --- /dev/null +++ b/internal/controller/job/job.go @@ -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 +} diff --git a/internal/controller/notice/notice.go b/internal/controller/notice/notice.go new file mode 100644 index 0000000..6209cb7 --- /dev/null +++ b/internal/controller/notice/notice.go @@ -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, ¬icev1.NoticeChannelItem{Id: v.Id, Code: v.Code, Name: v.Name, Enabled: v.Enabled, Config: v.Config, Remark: v.Remark}) + } + return ¬icev1.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 ¬icev1.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, ¬icev1.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 ¬icev1.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 ¬icev1.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 ¬icev1.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, ¬icev1.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 ¬icev1.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 ¬icev1.NoticeTestRes{Result: ok, Message: msg}, nil +} diff --git a/internal/dao/notice.go b/internal/dao/notice.go new file mode 100644 index 0000000..2a3fc3d --- /dev/null +++ b/internal/dao/notice.go @@ -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) } diff --git a/internal/model/do/admin_user.go b/internal/model/do/admin_user.go index dded414..b41eaed 100644 --- a/internal/model/do/admin_user.go +++ b/internal/model/do/admin_user.go @@ -11,14 +11,16 @@ import ( // AdminUser 是表 admin_user 的 Go 结构体,供 DAO 的 Where/Data 等操作使用。 type AdminUser struct { - g.Meta `orm:"table:admin_user, do:true"` - Id any // - Username any // - PasswordHash any // - Nickname any // - Status any // - LastLoginAt *gtime.Time // - CreatedAt *gtime.Time // - UpdatedAt *gtime.Time // - DeletedAt *gtime.Time // + g.Meta `orm:"table:admin_user, do:true"` + Id any // + Username any // + PasswordHash any // + Nickname any // + Status any // + BarkDeviceId any // + PushplusToken any // + LastLoginAt *gtime.Time // + CreatedAt *gtime.Time // + UpdatedAt *gtime.Time // + DeletedAt *gtime.Time // } diff --git a/internal/model/do/notice.go b/internal/model/do/notice.go new file mode 100644 index 0000000..567bf7f --- /dev/null +++ b/internal/model/do/notice.go @@ -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 // +} diff --git a/internal/model/dto/admin_rbac.go b/internal/model/dto/admin_rbac.go index c196cd8..8ac6224 100644 --- a/internal/model/dto/admin_rbac.go +++ b/internal/model/dto/admin_rbac.go @@ -8,27 +8,33 @@ type PageQuery struct { } type AdminItem struct { - Id uint64 - Username string - Nickname string - Status int - RoleIds []uint64 - RoleNames []string - CreatedAt string + Id uint64 + Username string + Nickname string + Status int + BarkDeviceId string + PushplusToken string + RoleIds []uint64 + RoleNames []string + CreatedAt string } type AdminCreateInput struct { - Username string - Password string - Nickname string - RoleIds []uint64 + Username string + Password string + Nickname string + BarkDeviceId string + PushplusToken string + RoleIds []uint64 } type AdminUpdateInput struct { - Id uint64 - Nickname string - Status int - RoleIds []uint64 + Id uint64 + Nickname string + Status int + BarkDeviceId string + PushplusToken string + RoleIds []uint64 } type RoleItem struct { diff --git a/internal/model/dto/job.go b/internal/model/dto/job.go new file mode 100644 index 0000000..46de64c --- /dev/null +++ b/internal/model/dto/job.go @@ -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 +} diff --git a/internal/model/dto/notice.go b/internal/model/dto/notice.go new file mode 100644 index 0000000..300cb9a --- /dev/null +++ b/internal/model/dto/notice.go @@ -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 +} diff --git a/internal/model/entity/admin_user.go b/internal/model/entity/admin_user.go index eaba73f..7b93fd9 100644 --- a/internal/model/entity/admin_user.go +++ b/internal/model/entity/admin_user.go @@ -10,13 +10,15 @@ import ( // AdminUser 是表 admin_user 的 Go 结构体。 type AdminUser struct { - Id uint64 `json:"id" orm:"id" description:""` // - Username string `json:"username" orm:"username" description:""` // - PasswordHash string `json:"passwordHash" orm:"password_hash" description:""` // - Nickname string `json:"nickname" orm:"nickname" description:""` // - Status int `json:"status" orm:"status" description:""` // - LastLoginAt *gtime.Time `json:"lastLoginAt" orm:"last_login_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:""` // + Id uint64 `json:"id" orm:"id" description:""` // + Username string `json:"username" orm:"username" description:""` // + PasswordHash string `json:"passwordHash" orm:"password_hash" description:""` // + Nickname string `json:"nickname" orm:"nickname" description:""` // + Status int `json:"status" orm:"status" description:""` // + BarkDeviceId string `json:"barkDeviceId" orm:"bark_device_id" description:"Bark设备ID"` // + PushplusToken string `json:"pushplusToken" orm:"pushplus_token" description:"pushplus token"` // + LastLoginAt *gtime.Time `json:"lastLoginAt" orm:"last_login_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:""` // } diff --git a/internal/model/entity/notice.go b/internal/model/entity/notice.go new file mode 100644 index 0000000..cb25dc1 --- /dev/null +++ b/internal/model/entity/notice.go @@ -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:"创建时间"` +} diff --git a/internal/service/admin/admin/admin/admin.go b/internal/service/admin/admin/admin/admin.go index 748fc68..4330c2a 100644 --- a/internal/service/admin/admin/admin/admin.go +++ b/internal/service/admin/admin/admin/admin.go @@ -60,6 +60,7 @@ func (s *adminManage) List(ctx context.Context, q dto.PageQuery) ([]*dto.AdminIt } items = append(items, &dto.AdminItem{ Id: a.Id, Username: a.Username, Nickname: a.Nickname, Status: a.Status, + BarkDeviceId: a.BarkDeviceId, PushplusToken: a.PushplusToken, RoleIds: roleIds, RoleNames: roleNames, 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{ Username: in.Username, PasswordHash: string(hash), Nickname: in.Nickname, Status: 1, + BarkDeviceId: in.BarkDeviceId, PushplusToken: in.PushplusToken, }).InsertAndGetId() if err != nil { 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 { - 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 { return gerror.Wrap(err, "update admin") } diff --git a/internal/service/job/job.go b/internal/service/job/job.go new file mode 100644 index 0000000..76f66bd --- /dev/null +++ b/internal/service/job/job.go @@ -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) + } +} diff --git a/internal/service/notice/notice.go b/internal/service/notice/notice.go new file mode 100644 index 0000000..f52223a --- /dev/null +++ b/internal/service/notice/notice.go @@ -0,0 +1,352 @@ +// Package notice 提供统一通知服务(渠道/规则/发送/日志)。 +// 发送实现:Bark 走路径式 key(POST {baseUrl}/{deviceKey} JSON);pushplus 走官方 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 ¬ice{} } + +// 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": + // 渠道配置取 baseUrl(JSON 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 + } + } + // 路径式 key:POST {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") } diff --git a/internal/service/recruitment/scheduler.go b/internal/service/recruitment/scheduler.go index eba9fa3..27681a2 100644 --- a/internal/service/recruitment/scheduler.go +++ b/internal/service/recruitment/scheduler.go @@ -3,27 +3,24 @@ package recruitment import ( "context" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gcron" + "service.xpcool.com/internal/service/job" ) -// StartScheduler 注册定时任务:每日 03:00 增量抓取全部启用源,08:00 推送招聘早报。 -// 调度器随 service 进程常驻,不额外引入系统 crontab。 -// 注意:gcron 表达式为 6 段式(秒 分 时 日 月 周),5 段标准 cron 会注册失败。 -func StartScheduler(ctx context.Context) { - if _, err := gcron.Add(ctx, "0 0 3 * * *", func(c context.Context) { - _, summary, e := Recruitment().Trigger(c, 0, false) +// RegisterTasks 把招聘模块的定时任务注册到自动任务调度器(由 job 模块统一纳管)。 +// 原 StartScheduler 直接注册 gcron 的方式已废弃:任务定义存 auto_job 表(016 SQL 种子), +// 由 job.StartScheduler 按 DB 配置统一调度,支持启停/改 cron/运行日志/完成通知。 +func RegisterTasks() { + // 每日 03:00 增量抓取全部启用源 + job.Job().Register("recruit-crawl-daily", func(ctx context.Context) (string, error) { + _, summary, e := Recruitment().Trigger(ctx, 0, false) if e != nil { - g.Log().Errorf(c, "recruit daily crawl failed: %v", e) - } else { - g.Log().Infof(c, "recruit daily crawl done: %s", summary) + return summary, e } - }, "recruit-crawl-daily"); err != nil { - g.Log().Errorf(ctx, "add recruit crawl cron failed: %v", err) - } - if _, err := gcron.Add(ctx, "0 0 8 * * *", func(c context.Context) { - sendDailyDigest(c) - }, "recruit-push-daily"); err != nil { - g.Log().Errorf(ctx, "add recruit push cron failed: %v", err) - } + return summary, nil + }) + // 每日 08:00 推送招聘早报(订阅列表) + job.Job().Register("recruit-push-daily", func(ctx context.Context) (string, error) { + sendDailyDigest(ctx) + return "招聘早报推送完成", nil + }) } diff --git a/manifest/sql/016_user_notice_job.sql b/manifest/sql/016_user_notice_job.sql new file mode 100644 index 0000000..a064fd2 --- /dev/null +++ b/manifest/sql/016_user_notice_job.sql @@ -0,0 +1,152 @@ +-- 016_user_notice_job.sql +-- 用户管理扩展 + 通知模块 + 自动任务模块(建表 + 菜单 + 种子)。 +-- 幂等:ALTER TABLE ADD COLUMN 用 IF NOT EXISTS(MySQL 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);