Compare commits

...

10 Commits

Author SHA1 Message Date
夏犀麟
5e517a2d95 chore: 移除误提交的本地测试产物并忽略
Some checks failed
Build and Deploy (service.xpcool.com) / build-and-deploy (push) Failing after 33s
2026-08-27 23:20:53 +08:00
夏犀麟
abd81486ad refactor(api): 去除 api 层 /v1 目录,统一为 api/<模块>/<子模块> 层级规则(包名=路径段下划线连接) 2026-08-27 23:20:32 +08:00
夏犀麟
3d24dbd8cb feat(auth): 认证链路联调修复——401 语义化 + 登出撤销会话 + 零配置兜底
- 未授权响应改为 HTTP 401(原 200+code10002 vben 拦截器不识别,
  token 过期永远不触发静默刷新/重登)
- 新增 response.JSONWithStatus;三处 401(UserAuth/AdminAuthOnly/AdminAuth)切换
- LogoutReq 支持 refreshToken:登出时撤销刷新会话(幂等),阻止设备续期;
  Refresh 时顺带清理已过期会话行
- IAdminAuth 新增 Revoke;dao/internal/admin_user.go 补 bark_device_id/
  pushplus_token 列映射(与 016 SQL/0569c70 对齐,恢复 int/gtime 类型映射)
- injectEnv 开发模式兜底: 等占位符未注入 env 时回填本地默认 DSN,
  任何启动方式(GoLand/命令行/go run)零配置可跑;prod 不兜底
2026-08-27 23:00:53 +08:00
夏犀麟
e868c1ef9c docs: 通知/任务模块变更记录 2026-08-27 22:59:21 +08:00
夏犀麟
0569c70690 feat(notice+job): 通知模块(渠道/规则/日志/测试) + 自动任务管理(DB驱动gcron) + 用户表加Bark/pushplus字段 2026-08-27 22:58:33 +08:00
夏犀麟
e18b25f32a fix(recruitment): 招聘中心菜单 96→97,为 015 安全日志菜单(96)让位
015_server_security.sql 已在生产部署使用 id=96(安全日志,挂系统监控),
招聘中心不能再占 96。95=新房预售、96=安全日志、97=招聘中心。
2026-08-27 12:27:44 +08:00
夏犀麟
743683efdd fix(recruitment): 菜单种子主键冲突,招聘中心 95→96 避免覆盖新房预售
003_menu.sql 的招聘中心一级菜单写死 id=95,与 014_house_presale_menu.sql 的
新房预售菜单(id=95,挂看房中心 90)ON DUPLICATE 互相覆盖,后导入者赢。
本地已实际发生覆盖。改为 id=96,951-954 父级同步改 96,超管角色绑定列表同步。
2026-08-27 12:22:50 +08:00
夏犀麟
1d451499b9 feat(security): 服务器安全日志模块——上报/查询/统计接口 + 015建表菜单 2026-08-27 11:30:00 +08:00
夏犀麟
a85a520af7 chore: gitignore 忽略本地验证用二进制 service_test.exe 2026-08-27 10:50:33 +08:00
夏犀麟
f81ce64fdd fix(recruitment): gcron 表达式改 6 段式,修复每日抓取/推送任务从未注册
gcron 不支持 5 段标准 cron,'0 3 * * *'/'0 8 * * *' 注册时报 invalid pattern
被 ERRO 日志吞掉,导致线上每日 03:00 增量抓取与 08:00 招聘早报推送从未执行。
改为 '0 0 3 * * *' / '0 0 8 * * *'。
2026-08-27 10:50:23 +08:00
42 changed files with 2180 additions and 125 deletions

2
.gitignore vendored
View File

@ -31,3 +31,5 @@ log/
# GoLand 项目级运行配置含数据库口令禁止提交
.run/
service_test.exe
service_test2*

View File

@ -1,6 +1,11 @@
# service.xpcool.com 变更记录
> 倒序最新在上格式YYYY-MM-DD | 类型 | 摘要
2026-08-27 | CHG | 通知模块+自动任务+用户渠道字段016 SQLadmin_user bark_device_id/pushplus_token notice_channel/rule/log + auto_job/auto_job_log 5 菜单 98 通知中心(980规则/981渠道/982日志)/99 自动任务(990任务/991日志) + type=2 权限渠道/规则/任务种子人员管理菜单改名用户管理api/notice + api/job11 接口全 POST URL 参数service/noticeSend 统一发送Bark 路径式 POST /{key} + pushplus 官方接口渠道/规则/日志/测试service/jobDB 驱动 gcronList/Save/Trigger/LogList + Register 业务函数注册 + 完成/失败自动通知 job_done/job_failrecruitment scheduler RegisterTasks 纳管 StartScheduler 废弃用户 admin 接口支持渠道字段已部署容器重建 api.json 20011 路由全注册调度器无 handler 缺失告警种子数据验证 OK
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 | 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 | 看房数据扩充013_house_presale.sql 建预售证表presale_no 唯一house_community avg_price 字段010 SQL 同步 + gen dao 更新 entity/do AvgPricego build 通过

View File

@ -5,13 +5,15 @@ import "github.com/gogf/gf/v2/frame/g"
// AdminItem 管理员列表中的一行。
type AdminItem struct {
Id uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status int `json:"status"`
RoleIds []uint64 `json:"roleIds"`
RoleNames []string `json:"roleNames"`
CreatedAt string `json:"createdAt"`
Id uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Status int `json:"status"`
BarkDeviceId string `json:"barkDeviceId"`
PushplusToken string `json:"pushplusToken"`
RoleIds []uint64 `json:"roleIds"`
RoleNames []string `json:"roleNames"`
CreatedAt string `json:"createdAt"`
}
// AdminListReq 分页查询管理员。
@ -30,11 +32,13 @@ type AdminListRes struct {
// AdminCreateReq 创建管理员。
type AdminCreateReq struct {
g.Meta `path:"/admin/create" method:"post" tags:"Admin/Admin" summary:"创建管理员"`
Username string `json:"username" v:"required"`
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
Nickname string `json:"nickname"`
RoleIds []uint64 `json:"roleIds"`
g.Meta `path:"/admin/create" method:"post" tags:"Admin/Admin" summary:"创建管理员"`
Username string `json:"username" v:"required"`
Password string `json:"password" v:"required|min-length:6#password required|password too short"`
Nickname string `json:"nickname"`
BarkDeviceId string `json:"barkDeviceId"`
PushplusToken string `json:"pushplusToken"`
RoleIds []uint64 `json:"roleIds"`
}
// AdminCreateRes 是 AdminCreateReq 的响应。
@ -44,11 +48,13 @@ type AdminCreateRes struct {
// AdminUpdateReq 更新管理员的资料、状态与角色。
type AdminUpdateReq struct {
g.Meta `path:"/admin/update/{id}" method:"post" tags:"Admin/Admin" summary:"更新管理员"`
Id uint64 `json:"id" in:"path" v:"required"`
Nickname string `json:"nickname"`
Status int `json:"status"`
RoleIds []uint64 `json:"roleIds"`
g.Meta `path:"/admin/update/{id}" method:"post" tags:"Admin/Admin" summary:"更新管理员"`
Id uint64 `json:"id" in:"path" v:"required"`
Nickname string `json:"nickname"`
Status int `json:"status"`
BarkDeviceId string `json:"barkDeviceId"`
PushplusToken string `json:"pushplusToken"`
RoleIds []uint64 `json:"roleIds"`
}
// AdminUpdateRes 是 AdminUpdateReq 的响应。

View File

@ -50,9 +50,11 @@ type RefreshReq struct {
// RefreshRes 是 RefreshReq 的响应。
type RefreshRes LoginRes
// LogoutReq 结束管理员会话。
// LogoutReq 结束管理员会话。refreshToken 可选携带:
// 传入时撤销对应刷新会话,阻止该设备继续续期(幂等)。
type LogoutReq struct {
g.Meta `path:"/system/auth/logout" method:"post" tags:"Admin/System/Auth" summary:"管理员登出"`
g.Meta `path:"/system/auth/logout" method:"post" tags:"Admin/System/Auth" summary:"管理员登出"`
RefreshToken string `json:"refreshToken"`
}
// LogoutRes 是 LogoutReq 的响应。

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

@ -0,0 +1,73 @@
// Package job_v1 自动任务管理模块接口契约DB 驱动的 gcron 调度 + 运行记录)。
// 规范2026-08-27全部 POSTURL 不含参数;入参一律 body。
package job
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/notice.go Normal file
View File

@ -0,0 +1,128 @@
// Package notice_v1 通知模块接口契约(统一推送管理:渠道/规则/日志/测试)。
// 规范2026-08-27全部 POSTURL 不含参数;入参一律 body。
package notice
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

@ -1,6 +1,6 @@
// Package recruitment_v1 招聘考试聚合模块接口契约。
// 规范2026-08-27全部 POSTURL 不含任何参数(查询/路径参数均禁止);入参一律走 body。
package recruitment_v1
package recruitment
import "github.com/gogf/gf/v2/frame/g"

View File

@ -0,0 +1,91 @@
// Package serversecurity_v1 服务器安全监控日志模块接口契约。
// 规范2026-08-27全部 POSTURL 不含任何参数(查询/路径参数均禁止);入参一律走 body。
package serversecurity
import "github.com/gogf/gf/v2/frame/g"
// ---------- 上报宿主机采集脚本调用open 组 + 内部 token 校验) ----------
// SecurityLogItem 单条安全日志。
type SecurityLogItem struct {
LogTime string `json:"logTime"` // 事件发生时间 YYYY-MM-DD HH:MM:SS
SrcIp string `json:"srcIp"` // 来源 IP
SrcPort int `json:"srcPort"` // 来源端口
DestPort int `json:"destPort"` // 目标端口(如 22025
EventType string `json:"eventType"` // failed_ssh / accepted_ssh / banned / unbanned
Detail string `json:"detail"` // 日志详情/原文
}
// SecurityLogReportReq 上报请求。
type SecurityLogReportReq struct {
g.Meta `path:"/security/log/report" method:"post" tags:"Open/Security" summary:"上报服务器安全日志(内部脚本调用)"`
Token string `json:"token" v:"required"` // 内部上报令牌
List []SecurityLogItem `json:"list" v:"required|min-length:1"`
}
// SecurityLogReportRes 上报结果。
type SecurityLogReportRes struct {
Accepted int `json:"accepted"` // 成功入库条数
}
// ---------- 查询admin 组,受权限保护) ----------
// SecurityLogListReq 分页查询请求。
type SecurityLogListReq struct {
g.Meta `path:"/server-security/log/list" method:"post" tags:"Admin/Security/Log" summary:"安全日志分页查询"`
Page int `json:"page" d:"1" v:"min:1"`
Size int `json:"size" d:"10" v:"min:1|max:100"`
SrcIp string `json:"srcIp"` // 来源 IP 精确/模糊
EventType string `json:"eventType"` // 事件类型过滤(空=全部)
DestPort int `json:"destPort"` // 目标端口(0=全部)
DateFrom string `json:"dateFrom"` // 起始时间 YYYY-MM-DD HH:MM:SS
DateTo string `json:"dateTo"` // 结束时间 YYYY-MM-DD HH:MM:SS
}
// SecurityLogListItem 日志列表项。
type SecurityLogListItem struct {
Id uint64 `json:"id"`
LogTime string `json:"logTime"`
SrcIp string `json:"srcIp"`
SrcPort int `json:"srcPort"`
DestPort int `json:"destPort"`
EventType string `json:"eventType"`
EventName string `json:"eventName"` // 派生:事件中文名
Detail string `json:"detail"`
CreatedAt string `json:"createdAt"`
}
// SecurityLogListRes 分页查询结果。
type SecurityLogListRes struct {
List []*SecurityLogListItem `json:"list"`
Total int `json:"total"`
}
// ---------- 统计admin 组,受权限保护) ----------
// SecurityLogStatsReq 统计请求。
type SecurityLogStatsReq struct {
g.Meta `path:"/server-security/log/stats" method:"post" tags:"Admin/Security/Log" summary:"安全日志统计(默认最近24h)"`
DateFrom string `json:"dateFrom"` // 起始时间(空=24小时前)
DateTo string `json:"dateTo"` // 结束时间(空=当前)
}
// SecurityTopIp TOP 攻击来源 IP。
type SecurityTopIp struct {
SrcIp string `json:"srcIp"`
Count int `json:"count"`
}
// SecurityByType 按事件类型分布。
type SecurityByType struct {
EventType string `json:"eventType"`
EventName string `json:"eventName"`
Count int `json:"count"`
}
// SecurityLogStatsRes 统计结果。
type SecurityLogStatsRes struct {
Total int `json:"total"` // 范围内总记录
Failed int `json:"failed"` // SSH 爆破尝试
Banned int `json:"banned"` // fail2ban 封禁
Accepted int `json:"accepted"` // 成功登录
TopIps []SecurityTopIp `json:"topIps"` // TOP 攻击源(按条数)
ByType []SecurityByType `json:"byType"` // 按类型分布
}

View File

@ -4,15 +4,20 @@ 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"
"service.xpcool.com/internal/middleware"
@ -31,35 +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")
// 服务器安全日志上报令牌(宿主机采集脚本携带,接口侧校验)。
setIfEmpty(adapter, "internalToken", "INTERNAL_TOKEN")
}
var (
@ -86,9 +115,13 @@ 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)
group.Bind(openctl.New()) // Open tools API for frontends, no auth required.
group.Bind(openctl.New()) // Open tools API for frontends, no auth required.
group.Bind(serversecurityctl.NewReport()) // 安全日志上报(宿主机脚本,内部令牌校验)。
})
s.Group("/api/service/user", func(group *ghttp.RouterGroup) {
group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse)
@ -112,10 +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
},

View File

@ -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

View File

@ -46,8 +46,12 @@ func (c *AuthController) Refresh(ctx context.Context, req *authv1.RefreshReq) (r
return &authv1.RefreshRes{AccessToken: p.AccessToken, RefreshToken: p.RefreshToken, ExpiresIn: p.ExpiresIn, AdminID: id}, nil
}
// Logout 结束管理员会话。无状态 JWT 登出依赖客户端
// 丢弃令牌,该端点对任意已登录管理员始终成功。
// Logout 结束管理员会话。无状态 JWT 的 access token 无法立即作废(等自然过期),
// 但携带 refreshToken 时可撤销刷新会话,阻止该设备继续续期。
// 幂等设计:即使令牌无效也返回成功,保证前端登出流程不阻塞。
func (c *AuthController) Logout(ctx context.Context, req *authv1.LogoutReq) (res *authv1.LogoutRes, err error) {
if req.RefreshToken != "" {
_ = auth.AdminAuth().Revoke(ctx, req.RefreshToken)
}
return &authv1.LogoutRes{}, nil
}

View File

@ -0,0 +1,68 @@
// Package job 自动任务模块控制器(绑定 admin 受权限保护组)。
package job
import (
"context"
jobv1 "service.xpcool.com/api/job"
"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"
"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
}

View File

@ -3,7 +3,7 @@ package recruitment
import (
"context"
recruitmentv1 "service.xpcool.com/api/recruitment/v1"
recruitmentv1 "service.xpcool.com/api/recruitment"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service/recruitment"
)

View File

@ -0,0 +1,21 @@
// Package serversecurity 服务器安全日志模块控制器。
// 分为两个 ControllerReportController 绑定 open 组(宿主机脚本上报,内部令牌鉴权);
// ManageController 绑定 admin 受权限保护组(查询/统计)。
package serversecurity
import (
serversecurityv1 "service.xpcool.com/api/serversecurity"
"service.xpcool.com/internal/model/dto"
)
// toInput 将 API 上报项转为服务层入参。
func toInput(items []serversecurityv1.SecurityLogItem) []dto.SecurityLogInput {
out := make([]dto.SecurityLogInput, 0, len(items))
for _, it := range items {
out = append(out, dto.SecurityLogInput{
LogTime: it.LogTime, SrcIp: it.SrcIp, SrcPort: it.SrcPort,
DestPort: it.DestPort, EventType: it.EventType, Detail: it.Detail,
})
}
return out
}

View File

@ -0,0 +1,57 @@
// Package serversecurity 管理控制器:绑定 admin 受权限保护组(查询/统计)。
package serversecurity
import (
"context"
serversecurityv1 "service.xpcool.com/api/serversecurity"
"service.xpcool.com/internal/model/dto"
"service.xpcool.com/internal/service/serversecurity"
)
// ManageController 实现安全日志查询与统计端点。
type ManageController struct{}
// NewManage 创建管理控制器。
func NewManage() *ManageController { return &ManageController{} }
// ListLog 安全日志分页查询(时间倒序)。
func (c *ManageController) ListLog(ctx context.Context, req *serversecurityv1.SecurityLogListReq) (res *serversecurityv1.SecurityLogListRes, err error) {
list, total, err := serversecurity.Security().List(ctx, dto.SecurityLogFilter{
Page: req.Page, Size: req.Size, SrcIp: req.SrcIp, EventType: req.EventType,
DestPort: req.DestPort, DateFrom: req.DateFrom, DateTo: req.DateTo,
})
if err != nil {
return nil, err
}
out := make([]*serversecurityv1.SecurityLogListItem, 0, len(list))
for i := range list {
v := &list[i]
out = append(out, &serversecurityv1.SecurityLogListItem{
Id: v.Id, LogTime: v.LogTime, SrcIp: v.SrcIp, SrcPort: v.SrcPort,
DestPort: v.DestPort, EventType: v.EventType, EventName: v.EventName,
Detail: v.Detail, CreatedAt: v.CreatedAt,
})
}
return &serversecurityv1.SecurityLogListRes{List: out, Total: total}, nil
}
// Stats 安全日志统计默认最近24小时
func (c *ManageController) Stats(ctx context.Context, req *serversecurityv1.SecurityLogStatsReq) (res *serversecurityv1.SecurityLogStatsRes, err error) {
st, err := serversecurity.Security().Stats(ctx, req.DateFrom, req.DateTo)
if err != nil {
return nil, err
}
topIps := make([]serversecurityv1.SecurityTopIp, 0, len(st.TopIps))
for _, v := range st.TopIps {
topIps = append(topIps, serversecurityv1.SecurityTopIp{SrcIp: v.SrcIp, Count: v.Count})
}
byType := make([]serversecurityv1.SecurityByType, 0, len(st.ByType))
for _, v := range st.ByType {
byType = append(byType, serversecurityv1.SecurityByType{EventType: v.EventType, EventName: v.EventName, Count: v.Count})
}
return &serversecurityv1.SecurityLogStatsRes{
Total: st.Total, Failed: st.Failed, Banned: st.Banned, Accepted: st.Accepted,
TopIps: topIps, ByType: byType,
}, nil
}

View File

@ -0,0 +1,24 @@
// Package serversecurity 上报控制器:绑定 open 组(无登录态),仅宿主机采集脚本调用。
package serversecurity
import (
"context"
serversecurityv1 "service.xpcool.com/api/serversecurity"
"service.xpcool.com/internal/service/serversecurity"
)
// ReportController 实现安全日志上报端点。
type ReportController struct{}
// NewReport 创建上报控制器。
func NewReport() *ReportController { return &ReportController{} }
// ReportLog 接收宿主机采集脚本上报的安全日志并入库(内部令牌鉴权)。
func (c *ReportController) ReportLog(ctx context.Context, req *serversecurityv1.SecurityLogReportReq) (res *serversecurityv1.SecurityLogReportRes, err error) {
n, err := serversecurity.Security().Report(ctx, req.Token, toInput(req.List))
if err != nil {
return nil, err
}
return &serversecurityv1.SecurityLogReportRes{Accepted: n}, nil
}

View File

@ -21,28 +21,32 @@ type AdminUserDao struct {
// AdminUserColumns 定义并存储表 admin_user 的列名。
type AdminUserColumns struct {
Id string //
Username string //
PasswordHash string //
Nickname string //
Status string //
LastLoginAt string //
CreatedAt string //
UpdatedAt string //
DeletedAt string //
Id string //
Username string //
PasswordHash string //
Nickname string //
Status string //
BarkDeviceId string //
PushplusToken string //
LastLoginAt string //
CreatedAt string //
UpdatedAt string //
DeletedAt string //
}
// adminUserColumns 保存表 admin_user 的列信息。
var adminUserColumns = AdminUserColumns{
Id: "id",
Username: "username",
PasswordHash: "password_hash",
Nickname: "nickname",
Status: "status",
LastLoginAt: "last_login_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
DeletedAt: "deleted_at",
Id: "id",
Username: "username",
PasswordHash: "password_hash",
Nickname: "nickname",
Status: "status",
BarkDeviceId: "bark_device_id",
PushplusToken: "pushplus_token",
LastLoginAt: "last_login_at",
CreatedAt: "created_at",
UpdatedAt: "updated_at",
DeletedAt: "deleted_at",
}
// NewAdminUserDao 创建并返回一个新的表数据访问 DAO 对象。

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

@ -0,0 +1,18 @@
// Package dao 服务器安全日志数据访问(主库默认分组,手写)。
package dao
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
type serverSecurityLogDao struct{ table string }
// ServerSecurityLog 安全日志表全局访问对象(主库 service
var ServerSecurityLog = serverSecurityLogDao{table: "server_security_log"}
func (d serverSecurityLogDao) Ctx(ctx context.Context) *gdb.Model {
return g.DB().Model(d.table).Safe()
}

View File

@ -0,0 +1,11 @@
package response
import "github.com/gogf/gf/v2/net/ghttp"
// JSONWithStatus 输出带 HTTP 状态码的统一 JSON 信封。
// 401 等需要触发前端认证拦截器vben 仅识别 HTTP 401的场景必须用它
// 普通业务错误仍用 JSONHTTP 200 + code 区分),避免前端按异常处理。
func JSONWithStatus(r *ghttp.Request, httpStatus, code int, message string, data any) {
r.Response.WriteStatus(httpStatus)
r.Response.WriteJsonExit(Body{Code: code, Message: message, Data: data})
}

View File

@ -55,7 +55,7 @@ func UserAuth(s *jwt.Service) ghttp.HandlerFunc {
return func(r *ghttp.Request) {
c, err := s.Parse(bearer(r), "access", "user")
if err != nil {
response.JSON(r, consts.CodeUnauthorized, "login required", nil)
response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "login required", nil)
return
}
r.SetCtxVar(UserIDKey, c.Subject)
@ -68,7 +68,7 @@ func AdminAuthOnly(s *jwt.Service) ghttp.HandlerFunc {
return func(r *ghttp.Request) {
c, err := s.Parse(bearer(r), "access", "admin")
if err != nil {
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "admin login required", nil)
return
}
r.SetCtxVar(AdminIDKey, c.Subject)
@ -83,7 +83,7 @@ func AdminAuth(s *jwt.Service, permissionLookup func(context.Context, string, st
start := time.Now()
c, err := s.Parse(bearer(r), "access", "admin")
if err != nil {
response.JSON(r, consts.CodeUnauthorized, "admin login required", nil)
response.JSONWithStatus(r, 401, consts.CodeUnauthorized, "admin login required", nil)
return
}
permission, err := permissionLookup(r.Context(), r.Method, r.URL.Path)

View File

@ -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 //
}

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

@ -0,0 +1,18 @@
// Package do 定义服务器安全日志数据对象(手写,未跑 gf gen
// 字段类型统一用 any与 gf gen 产物保持一致。
package do
import "github.com/gogf/gf/v2/frame/g"
// ServerSecurityLog 安全日志表 DO。
type ServerSecurityLog struct {
g.Meta `orm:"table:server_security_log, do:true"`
Id any // 主键
LogTime any // 事件发生时间
SrcIp any // 来源IP
SrcPort any // 来源端口
DestPort any // 目标端口
EventType any // 事件类型
Detail any // 日志详情
CreatedAt any // 入库时间
}

View File

@ -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 {

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

@ -0,0 +1,74 @@
// Package dto 定义服务器安全日志模块的服务边界对象(入参/出参)。
package dto
// SecurityLogFilter 安全日志查询筛选条件。
type SecurityLogFilter struct {
Page int // 页码,从 1 开始
Size int // 每页大小
SrcIp string // 来源 IP
EventType string // 事件类型(空=全部)
DestPort int // 目标端口(0=全部)
DateFrom string // 起始时间 YYYY-MM-DD HH:MM:SS
DateTo string // 结束时间 YYYY-MM-DD HH:MM:SS
}
// SecurityLogInput 上报入库的日志项。
type SecurityLogInput struct {
LogTime string // 事件发生时间 YYYY-MM-DD HH:MM:SS
SrcIp string // 来源 IP
SrcPort int // 来源端口
DestPort int // 目标端口
EventType string // 事件类型
Detail string // 日志详情
}
// SecurityLogVO 日志出参视图。
type SecurityLogVO struct {
Id uint64
LogTime string
SrcIp string
SrcPort int
DestPort int
EventType string
EventName string // 派生:事件中文名
Detail string
CreatedAt string
}
// SecurityStats 统计出参。
type SecurityStats struct {
Total int
Failed int
Banned int
Accepted int
TopIps []SecurityTopIp
ByType []SecurityByType
}
// SecurityTopIp TOP 攻击源。
type SecurityTopIp struct {
SrcIp string `json:"srcIp"`
Count int `json:"count"`
}
// SecurityByType 按类型分布。
type SecurityByType struct {
EventType string `json:"eventType"`
EventName string `json:"eventName"`
Count int `json:"count"`
}
// SecurityEventName 事件类型中文名。
func SecurityEventName(t string) string {
switch t {
case "failed_ssh":
return "SSH爆破尝试"
case "accepted_ssh":
return "SSH成功登录"
case "banned":
return "fail2ban封禁"
case "unbanned":
return "fail2ban解封"
}
return t
}

View File

@ -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:""` //
}

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

@ -0,0 +1,14 @@
// Package entity 定义服务器安全日志表结构(手写,未跑 gf gen
package entity
// ServerSecurityLog 服务器安全监控日志(宿主机采集脚本上报)。
type ServerSecurityLog struct {
Id uint64 `json:"id" orm:"id" description:"主键"`
LogTime string `json:"logTime" orm:"log_time" description:"事件发生时间"`
SrcIp string `json:"srcIp" orm:"src_ip" description:"来源IP"`
SrcPort int `json:"srcPort" orm:"src_port" description:"来源端口"`
DestPort int `json:"destPort" orm:"dest_port" description:"目标端口"`
EventType string `json:"eventType" orm:"event_type" description:"事件类型 failed_ssh/accepted_ssh/banned/unbanned"`
Detail string `json:"detail" orm:"detail" 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{
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")
}

View File

@ -21,6 +21,8 @@ import (
type IAdminAuth interface {
Login(context.Context, dto.AdminLoginInput) (*dto.TokenPair, uint64, error)
Refresh(context.Context, string) (*dto.TokenPair, uint64, error)
// Revoke 撤销刷新令牌会话(登出时调用),幂等:解析失败或已撤销均视为成功。
Revoke(context.Context, string) error
HasPermission(context.Context, uint64, string) (bool, error)
Info(context.Context, uint64) (*dto.AdminInfo, error)
Codes(context.Context, uint64) ([]string, error)
@ -64,6 +66,8 @@ func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair
if err != nil {
return nil, 0, response.Error(consts.CodeUnauthorized, "invalid refresh token")
}
// 顺手清理已过期的刷新会话,防止表无限膨胀(失败不影响主流程)。
_, _ = dao.AuthRefreshSession.Ctx(ctx).WhereLT("expired_at", gtime.Now()).Delete()
// 单次使用:撤销旧刷新会话并校验影响行数,重复使用旧令牌直接拒绝。
result, err := dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update()
if err != nil {
@ -76,6 +80,22 @@ func (s *adminAuth) Refresh(ctx context.Context, refresh string) (*dto.TokenPair
return s.issue(ctx, c.Subject, c.Terminal)
}
// Revoke 撤销刷新令牌会话:登出时由控制器携带 refreshToken 调用。
// 无状态 JWT 无法真正作废 access token等它自然过期默认 2h
// 但撤销 refresh 会话可阻止续期,等于关闭该设备的长期会话。
func (s *adminAuth) Revoke(ctx context.Context, refresh string) error {
c, err := s.tokens.Parse(refresh, "refresh", "admin")
if err != nil {
// 令牌无效/已过期:无需撤销,视为成功(幂等)。
return nil
}
_, err = dao.AuthRefreshSession.Ctx(ctx).Where(do.AuthRefreshSession{Jti: c.JTI}).WhereNull("revoked_at").Data(do.AuthRefreshSession{RevokedAt: gtime.Now()}).Update()
if err != nil {
return gerror.Wrap(err, "撤销刷新令牌会话失败")
}
return nil
}
// issue 签发令牌对并把刷新令牌的 JTI 落库,支持撤销与设备会话追踪。
func (s *adminAuth) issue(ctx context.Context, id uint64, terminal string) (*dto.TokenPair, uint64, error) {
access, refresh, exp, err := s.tokens.Issue(id, "admin", terminal)

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,26 +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。
func StartScheduler(ctx context.Context) {
if _, err := gcron.Add(ctx, "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 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
})
}

View File

@ -0,0 +1,155 @@
// Package serversecurity 提供服务器安全监控日志领域服务(采集上报/查询/统计)。
package serversecurity
import (
"context"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"service.xpcool.com/internal/consts"
"service.xpcool.com/internal/dao"
"service.xpcool.com/internal/library/response"
"service.xpcool.com/internal/model/dto"
)
// ISecurity 服务器安全日志领域服务接口。
type ISecurity interface {
Report(context.Context, string, []dto.SecurityLogInput) (int, error)
List(context.Context, dto.SecurityLogFilter) ([]dto.SecurityLogVO, int, error)
Stats(context.Context, string, string) (*dto.SecurityStats, error)
}
type security struct{}
var localSecurity ISecurity
// New 创建安全日志服务实现。
func New() ISecurity { return &security{} }
// Security 返回已注册的安全日志服务实现。
func Security() ISecurity {
if localSecurity == nil {
panic("Security implementation not registered")
}
return localSecurity
}
// RegisterServerSecurity 注册安全日志服务实现。
func RegisterServerSecurity(i ISecurity) { localSecurity = i }
// Report 校验内部令牌并把采集到的日志批量入库(幂等:按 log_time+src_ip+event_type 去重)。
func (s *security) Report(ctx context.Context, token string, items []dto.SecurityLogInput) (int, error) {
// 校验内部上报令牌(配置 internalToken来自环境变量 INTERNAL_TOKEN
expect := g.Cfg().MustGet(ctx, "internalToken").String()
if expect == "" || token != expect {
return 0, response.Error(consts.CodeUnauthorized, "内部上报令牌无效")
}
if len(items) == 0 {
return 0, nil
}
rows := make([]map[string]interface{}, 0, len(items))
for _, it := range items {
// 事件时间兜底:非法/空则用当前时间。
logTime := strings.TrimSpace(it.LogTime)
if logTime == "" {
logTime = gtime.Now().Format("Y-m-d H:i:s")
}
srcIp := strings.TrimSpace(it.SrcIp)
if len(srcIp) > 45 {
srcIp = srcIp[:45]
}
detail := strings.TrimSpace(it.Detail)
if len(detail) > 500 {
detail = detail[:500]
}
rows = append(rows, map[string]interface{}{
"log_time": logTime,
"src_ip": srcIp,
"src_port": it.SrcPort,
"dest_port": it.DestPort,
"event_type": strings.TrimSpace(it.EventType),
"detail": detail,
})
}
if _, err := dao.ServerSecurityLog.Ctx(ctx).Data(rows).Insert(); err != nil {
return 0, gerror.Wrap(err, "insert server security log")
}
return len(rows), nil
}
// List 分页查询安全日志(时间倒序)。
func (s *security) List(ctx context.Context, f dto.SecurityLogFilter) ([]dto.SecurityLogVO, int, error) {
m := dao.ServerSecurityLog.Ctx(ctx)
if f.SrcIp != "" {
m = m.Where("src_ip LIKE ?", "%"+f.SrcIp+"%")
}
if f.EventType != "" {
m = m.Where("event_type", f.EventType)
}
if f.DestPort > 0 {
m = m.Where("dest_port", f.DestPort)
}
if f.DateFrom != "" {
m = m.WhereGTE("log_time", f.DateFrom)
}
if f.DateTo != "" {
m = m.WhereLTE("log_time", f.DateTo)
}
total, err := m.Clone().Count()
if err != nil {
return nil, 0, gerror.Wrap(err, "count security log")
}
var list []dto.SecurityLogVO
if err = m.Clone().Page(f.Page, f.Size).OrderDesc("log_time").OrderDesc("id").Scan(&list); err != nil {
return nil, 0, gerror.Wrap(err, "query security log")
}
for i := range list {
list[i].EventName = dto.SecurityEventName(list[i].EventType)
}
return list, total, nil
}
// Stats 统计:总数/爆破/封禁/成功登录 + TOP 攻击源 + 按类型分布。
// 时间范围为闭区间,空则默认最近 24 小时。
func (s *security) Stats(ctx context.Context, dateFrom, dateTo string) (*dto.SecurityStats, error) {
if dateFrom == "" {
dateFrom = gtime.Now().AddDate(0, 0, -1).Format("Y-m-d H:i:s")
}
if dateTo == "" {
dateTo = gtime.Now().Format("Y-m-d H:i:s")
}
base := dao.ServerSecurityLog.Ctx(ctx).Where("log_time BETWEEN ? AND ?", dateFrom, dateTo)
st := &dto.SecurityStats{}
var err error
if st.Total, err = base.Clone().Count(); err != nil {
return nil, gerror.Wrap(err, "count total")
}
if st.Failed, err = base.Clone().Where("event_type", "failed_ssh").Count(); err != nil {
return nil, gerror.Wrap(err, "count failed")
}
if st.Banned, err = base.Clone().Where("event_type", "banned").Count(); err != nil {
return nil, gerror.Wrap(err, "count banned")
}
if st.Accepted, err = base.Clone().Where("event_type", "accepted_ssh").Count(); err != nil {
return nil, gerror.Wrap(err, "count accepted")
}
// TOP 攻击源(爆破+封禁优先)。
if err = base.Clone().WhereIn("event_type", g.Slice{"failed_ssh", "banned"}).
Fields("src_ip, COUNT(*) AS cnt").
Group("src_ip").OrderDesc("cnt").Limit(10).Scan(&st.TopIps); err != nil {
return nil, gerror.Wrap(err, "query top ips")
}
// 按类型分布。
if err = base.Clone().Fields("event_type, COUNT(*) AS cnt").
Group("event_type").Scan(&st.ByType); err != nil {
return nil, gerror.Wrap(err, "query by type")
}
for i := range st.ByType {
st.ByType[i].EventName = dto.SecurityEventName(st.ByType[i].EventType)
}
return st, nil
}

View File

@ -3,3 +3,5 @@ logger: { level: "warning", stdout: true }
database: { default: { link: "${DB_DSN}" }, recruitment: { link: "${RECRUITMENT_DB_DSN}" } }
jwt: { secret: "${JWT_SECRET}", accessExpire: "2h", refreshExpire: "720h" }
bark: { baseUrl: "${BARK_BASE_URL}", deviceKey: "${BARK_DEVICE_KEY}", pushTime: "${BARK_PUSH_TIME}" }
# 安全日志上报令牌宿主机采集脚本携带环境变量 INTERNAL_TOKEN 注入
internalToken: "${INTERNAL_TOKEN}"

View File

@ -0,0 +1,38 @@
-- 015_server_security.sql
-- 服务器安全监控日志建表 + 菜单/权限种子
-- 依赖service 主库admin_menu / admin_role_menu 已存在
-- 幂等CREATE TABLE IF NOT EXISTS + INSERT ... ON DUPLICATE KEY UPDATE + INSERT IGNORE可重复执行
-- ============ 1) 安全日志表 ============
CREATE TABLE IF NOT EXISTS `server_security_log` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`log_time` DATETIME NOT NULL COMMENT '事件发生时间',
`src_ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '来源IP(含IPv6)',
`src_port` INT NOT NULL DEFAULT 0 COMMENT '来源端口',
`dest_port` INT NOT NULL DEFAULT 0 COMMENT '目标端口(如22025)',
`event_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '事件类型 failed_ssh/accepted_ssh/banned/unbanned',
`detail` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '日志详情/原文',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_time` (`log_time`),
KEY `idx_ip` (`src_ip`),
KEY `idx_type` (`event_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务器安全监控日志(宿主机采集上报)';
-- ============ 2) 菜单挂到系统监控(id=5) ============
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(96, 5, '安全日志', 'mdi:shield-alert-outline', 1, 'security', 'monitor/security/index', 'server:security', 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;
-- ============ 3) API 权限type=2path 必须与实际路由一致 ============
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(960, 96, '查询', '', 2, 'POST /api/service/admin/server-security/log/list', '', 'server:security:list', 1, 1, 0, NOW(), NOW()),
(961, 96, '统计', '', 2, 'POST /api/service/admin/server-security/log/stats', '', 'server:security:stats', 2, 1, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), name=VALUES(name), type=VALUES(type),
path=VALUES(path), permission=VALUES(permission), sort=VALUES(sort), status=VALUES(status), hidden=VALUES(hidden), deleted_at=NULL;
-- ============ 4) 超管角色绑定 ============
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 (96, 960, 961);

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);

View File

@ -10,11 +10,11 @@
-- ============ 1) 招聘中心菜单type=1 ============
INSERT INTO admin_menu (id, parent_id, name, icon, type, path, component, permission, sort, status, hidden, created_at, updated_at) VALUES
(95, 0, '招聘中心', 'mdi:briefcase-search', 1, '/recruitment', '', 'recruitment:center', 7, 1, 0, NOW(), NOW()),
(951, 95, '招聘公告', 'mdi:file-document', 1, 'recruitment/info', 'recruitment/info/index', 'recruitment:info', 1, 1, 0, NOW(), NOW()),
(952, 95, '数据看板', 'mdi:chart-box', 1, 'recruitment/dashboard','recruitment/dashboard/index','recruitment:dashboard', 2, 1, 0, NOW(), NOW()),
(953, 95, '数据源', 'mdi:web-sync', 1, 'recruitment/crawler', 'recruitment/crawler/index', 'recruitment:crawler', 3, 1, 0, NOW(), NOW()),
(954, 95, '推送订阅', 'mdi:bell-ring', 1, 'recruitment/push', 'recruitment/push/index', 'recruitment:push', 4, 1, 0, NOW(), NOW())
(97, 0, '招聘中心', 'mdi:briefcase-search', 1, '/recruitment', '', 'recruitment:center', 7, 1, 0, NOW(), NOW()),
(951, 97, '招聘公告', 'mdi:file-document', 1, 'recruitment/info', 'recruitment/info/index', 'recruitment:info', 1, 1, 0, NOW(), NOW()),
(952, 97, '数据看板', 'mdi:chart-box', 1, 'recruitment/dashboard','recruitment/dashboard/index','recruitment:dashboard', 2, 1, 0, NOW(), NOW()),
(953, 97, '数据源', 'mdi:web-sync', 1, 'recruitment/crawler', 'recruitment/crawler/index', 'recruitment:crawler', 3, 1, 0, NOW(), NOW()),
(954, 97, '推送订阅', 'mdi:bell-ring', 1, 'recruitment/push', 'recruitment/push/index', 'recruitment:push', 4, 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;
@ -56,4 +56,4 @@ ON DUPLICATE KEY UPDATE parent_id=VALUES(parent_id), name=VALUES(name), icon=VAL
-- ============ 6) 超管角色绑定新菜单role_id=1 超管 ============
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 (95,951,952,953,954,9510,9511,9520,9521,9530,9531,9540,9541,9542,9543);
WHERE id IN (97,951,952,953,954,9510,9511,9520,9521,9530,9531,9540,9541,9542,9543);