diff --git a/cmd/main.go b/cmd/main.go index 6bde4b2..2e61df1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -90,6 +90,7 @@ func main() { if cfg.QQBot.Enabled { qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance, opsSvc) go qqBot.Start(ctx) + go qqBot.StartSubScheduler(ctx, 9, 0) } engine := gin.New() diff --git a/cmd/runbook_test.go b/cmd/runbook_test.go new file mode 100644 index 0000000..ffdd098 --- /dev/null +++ b/cmd/runbook_test.go @@ -0,0 +1,66 @@ +//go:build runbooktest +// +build runbooktest + +package main + +import ( + "fmt" + "os" + "path/filepath" + + "ops-assistant/internal/core/ops" +) + +func main() { + if len(os.Args) < 4 { + fmt.Println("usage: runbook_test ") + os.Exit(2) + } + dbPath := os.Args[1] + baseDir := os.Args[2] + cmd := os.Args[3] + inputs := map[string]string{} + // minimal parse for /cf dnsadd [true] [type] + parts := split(cmd) + if len(parts) >= 4 && parts[0] == "/cf" && parts[1] == "dnsadd" { + inputs["name"] = parts[2] + inputs["content"] = parts[3] + inputs["type"] = "A" + inputs["proxied"] = "false" + if len(parts) >= 5 { + if parts[4] == "true" { + inputs["proxied"] = "true" + if len(parts) >= 6 { + inputs["type"] = parts[5] + } + } else { + inputs["type"] = parts[4] + } + } + } + jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cf_dns_add", 1, inputs) + if err != nil { + fmt.Printf("ERR: %v\n", err) + os.Exit(1) + } + fmt.Printf("OK job=%d\n", jobID) +} + +func split(s string) []string { + out := []string{} + cur := "" + for _, r := range s { + if r == ' ' || r == '\t' || r == '\n' { + if cur != "" { + out = append(out, cur) + cur = "" + } + continue + } + cur += string(r) + } + if cur != "" { + out = append(out, cur) + } + return out +} diff --git a/dist/ops-assistant-v0.0.1-linux-amd64 b/dist/ops-assistant-v0.0.1-linux-amd64 deleted file mode 100755 index dd4b40c..0000000 Binary files a/dist/ops-assistant-v0.0.1-linux-amd64 and /dev/null differ diff --git a/dist/ops-assistant-v0.0.1-linux-amd64.sha256 b/dist/ops-assistant-v0.0.1-linux-amd64.sha256 deleted file mode 100644 index 2b3797f..0000000 --- a/dist/ops-assistant-v0.0.1-linux-amd64.sha256 +++ /dev/null @@ -1 +0,0 @@ -55bfe12944a42957532b9f63492d9ed8ca600419c4352ffa35344a62598bc019 dist/ops-assistant-v0.0.1-linux-amd64 diff --git a/docs/API_RESPONSE_CONVENTION.md b/docs/API_RESPONSE_CONVENTION.md new file mode 100644 index 0000000..7d65396 --- /dev/null +++ b/docs/API_RESPONSE_CONVENTION.md @@ -0,0 +1,36 @@ +# API Response Convention + +统一响应格式(JSON 接口): + +- 成功: +```json +{ + "code": "OK", + "message": "ok|success|...", + "data": { } +} +``` + +- 失败: +```json +{ + "code": "ERR_...", + "message": "..." +} +``` + +## 约定 + +1. 所有 `/api/v1/*` JSON 接口使用统一结构。 +2. 文件流接口(如 CSV 导出)成功时返回文件内容;失败时返回统一错误 JSON。 +3. 权限失败统一 `ERR_PERMISSION_DENIED`。 +4. 执行失败统一至少返回一个业务错误码(例如 `ERR_STEP_FAILED`)。 +5. `message` 用于人类可读,`code` 用于前端逻辑分支。 + +## 已接入范围(2026-03-10) + +- Ops: jobs/list/detail/cancel/retry +- Flags: list/patch +- Channels: list/patch/publish/reload/enable/disable/disable-all/test/apply +- Records: list/delete +- Audit: list diff --git a/docs/ai_command_guide.md b/docs/ai_command_guide.md new file mode 100644 index 0000000..c61d1a8 --- /dev/null +++ b/docs/ai_command_guide.md @@ -0,0 +1,150 @@ +# AI 命令解释知识库(用于自然语言→标准命令翻译) + +## 总原则 +- 目标:把用户的自然语言翻译成**唯一**的标准命令。 +- 只输出一行命令,不要解释。 +- 不能确定时输出 `FAIL`。 +- 标准命令必须以 `/` 开头。 + +--- + +## OPS 模块 +### /help +**用途**:显示 OPS 可用命令清单(同 /start /ops help)。 +**常见自然语**: +- 你能做什么 / 你会什么 +- 功能 / 菜单 / 帮助 / help +- 查看命令 / 看下命令 +**输出命令**: +- `/help` + +### /ops modules +**用途**:查看模块启用状态。 +**常见自然语**: +- 模块状态 / 查看模块 +- ops 模块 / ops modules +**输出命令**: +- `/ops modules` + +--- + +## CPA 模块 +### /cpa help +**用途**:显示 CPA 模块可用命令。 +**常见自然语**: +- cpa 帮助 / cpa help +**输出命令**: +- `/cpa help` + +**用途**:查询 CPA 服务状态与 usage 快照。 +**常见自然语**: +- cpa状态 / CPA状态 / cpa status / cpastatus +- cpa 服务状态 / cpa 运行状态 / cpa 正常吗 +- 查下 cpa / 看下 cpa 状态 +**输出命令**: +- `/cpa status` + +### /cpa usage backup +**用途**:导出实时 usage 并打包备份。 +**常见自然语**: +- cpa 备份 / 备份 cpa +- 备份 usage / cpa usage 备份 +- 导出 cpa 使用情况 +**输出命令**: +- `/cpa usage backup` + +### /cpa usage restore +**用途**:从备份恢复 usage。 +**常见自然语**: +- 恢复 cpa / 回滚 cpa +- 用备份 恢复 +- 恢复 usage +**输出命令**: +- `/cpa usage restore ` + +--- + +## Cloudflare 模块 +### /cf status +**用途**:查看 Cloudflare 配置状态。 +**常见自然语**: +- cf 状态 / cloudflare 状态 +- cf 配置怎么样 +**输出命令**: +- `/cf status` + +### /cf zones +**用途**:列出 zone 列表。 +**常见自然语**: +- cf zones / cf 区域列表 / 站点列表 +**输出命令**: +- `/cf zones` + +### /cf dns list +**用途**:列出指定 zone 的 DNS 记录。 +**常见自然语**: +- 列出 DNS / 查看 DNS / cf dns list +- zone 的 dns 记录 +**输出命令**: +- `/cf dns list ` + +### /cf dns update [ttl] [proxied] +**用途**:更新 DNS 记录。 +**常见自然语**: +- 更新 dns / 修改解析 +- 把 改成 +**输出命令**: +- `/cf dns update [ttl] [proxied]` + +### /cf dnsadd [true] [type] +**用途**:新增 DNS 记录(name 自动匹配 zone)。 +**常见自然语**: +- 新增解析 / 添加 DNS +- 增加 -> +**输出命令**: +- `/cf dnsadd [true] [type]` + +### /cf dnsset [true] +**用途**:按 record_id 修改内容(可选开启代理)。 +**常见自然语**: +- 更新记录内容 / 改 IP +**输出命令**: +- `/cf dnsset [true]` + +### /cf dnsdel YES +**用途**:删除 DNS 记录(需 YES 确认)。 +**常见自然语**: +- 删除 DNS 记录 / 删解析 +**输出命令**: +- `/cf dnsdel YES` + +### /cf dnsproxy on|off +**用途**:切换代理开关(不改 content)。 +**常见自然语**: +- 开启/关闭代理 +**输出命令**: +- `/cf dnsproxy on|off` + +### /cf workers list +**用途**:列出 Workers。 +**常见自然语**: +- 列出 workers / cf workers +**输出命令**: +- `/cf workers list` + +--- + +## Mail 模块 +### /mail status +**用途**:查看邮件服务/配置状态。 +**常见自然语**: +- 邮件状态 / mail 状态 +- 邮箱配置是否正常 +**输出命令**: +- `/mail status` + +--- + +## 兜底规则 +- 无法确定命令或缺少关键参数时输出 `FAIL`。 +- 不要猜测 zone_id/record_id/backup_id 等关键参数。 diff --git a/docs/architecture-core.md b/docs/architecture-core.md new file mode 100644 index 0000000..a93e15f --- /dev/null +++ b/docs/architecture-core.md @@ -0,0 +1,104 @@ +# Ops-Assistant Core Architecture (v0.0.1) + +> 目标:程序可独立运行,AI 可选接入但无执行权;新通道/新功能模块可低成本适配接入。 + +## 1. 设计红线(强约束) + +1. **执行权唯一入口**:所有变更类动作只能经 `Command -> Policy -> Runbook Engine`。 +2. **AI 不可执行**:AI 仅能做建议/解释,不可直接调用执行器。 +3. **通道与业务解耦**:TG/QQ/Feishu 仅适配输入输出,不承载业务规则。 +4. **模块与核心解耦**:CPA/CF/Mail 仅注册命令和构建 runbook 请求。 +5. **全链路可审计**:job/step、operator、request_id、risk_level、input_json 必须可追溯。 + +--- + +## 2. 分层结构 + +1) **Channel Adapter 层** +- 责任:收消息、身份映射、回消息。 +- 输入:平台消息。 +- 输出:统一消息结构(text/operator/channel)。 + +2) **Command Runtime 层** +- 责任:命令解析、路由、参数校验。 +- 输出:模块请求(Module Request)。 + +3) **Policy Gate 层** +- 责任:权限、feature flag、confirm token、dry-run。 +- 输出:允许/拒绝(含原因)。 + +4) **Runbook Engine 层** +- 责任:确定性执行 step(白名单动作)、锁、超时、断言、取消。 + +5) **Audit/State 层** +- 责任:记录 job/step 状态和执行证据。 + +6) **AI Advisor(可选)** +- 责任:建议命令、参数补全、解释结果。 +- 限制:**无执行器句柄、无 DB 写权限、无外部动作权限**。 + +--- + +## 3. 接入标准 + +### 3.1 新通道接入(最小接口) +- 实现统一消息输入 +- 实现统一回复输出 +- 提供身份映射(operator_id) + +> 新通道不允许直接操作 runbook / db。 + +### 3.2 新功能模块接入(最小接口) +- 注册命令前缀(如 `/cpa` `/cf` `/mail`) +- 输入校验 +- 构建 runbook 请求(name + inputs + meta + policy) + +> 新模块不允许直接执行 shell/ssh,只能请求 runbook 引擎。 + +### 3.3 当前注册机制 +- Parser 按 `/xxx` 自动识别 `module=xxx` +- Registry 支持两种注册: + - 精确命令注册(`Register("/health", ...)`) + - 模块前缀注册(`RegisterModule("cpa", ...)`) +- 推荐新模块使用 `RegisterModule`,降低接入成本。 + +--- + +## 4. AI 介入模式 + +- `off`:完全关闭 AI(默认可用) +- `suggest`:允许 AI 给出命令建议,不自动执行 +- `explain`:允许 AI 解释结果,不自动执行 + +### 安全边界 +- AI 输出必须是文本建议,不是可执行指令。 +- 任何执行动作都需要用户命令触发并通过 Policy Gate。 + +--- + +## 5. 统一执行链 + +`Channel -> Command -> Module.BuildRequest -> Policy.Check -> Runbook.Execute -> Audit -> Reply` + +这条链是扩展 CF/Mail 时的唯一合法路径。 + +--- + +## 6. 当前状态与后续 + +### 已具备 +- Runbook 引擎(执行/锁/超时/取消) +- CPA 模块(status/backup/restore) +- 任务审计(jobs/steps) +- CF/Mail 模块骨架(`/cf status`、`/mail status` 占位) + +### 下一步(框架整理) +1. 将模块内零散 gate 逻辑统一沉淀到 `core/policy` +2. 形成通道适配模板和模块适配模板 +3. 引入 `core/ai` 的 Noop Advisor(默认 off) +4. 在 CF/Mail 骨架上补齐真实 runbook(不改核心) + +### 模块管理可观测性 +- 聊天命令:`/ops modules` 查看注册模块与启用状态 +- Web 首页:模块状态卡片(读取 `enable_module_*`) +- 模块启停:统一通过 feature flag 管理,不改代码路径 diff --git a/docs/backend-independence-status.md b/docs/backend-independence-status.md new file mode 100644 index 0000000..7cd0a21 --- /dev/null +++ b/docs/backend-independence-status.md @@ -0,0 +1,89 @@ +# Ops-Assistant 后端独立化完成清单(Phase 1~6) + +更新日期:2026-03-10 + +--- + +## 总览 + +目标:`ops-assistant` 后端从旧 `xiaji-go` 记账心智中独立,形成 Ops 场景可扩展架构。 + +当前结论: +- ✅ 核心链路已成型:`Command -> Module -> Policy -> Runbook -> Audit -> Reply` +- ✅ 三模块(CPA/CF/Mail)已纳入统一执行骨架(CF/Mail 为安全占位 runbook) +- ✅ Ops Web 数据面已具备(dashboard/modules/jobs/channels/audit/flags) +- ✅ Legacy 兼容层已进入可观测可下线阶段(usage/trend/readiness) + +--- + +## Phase 状态 + +## Phase 1:API 基础收口 +- ✅ 新增/改造接口统一 `{code,message,data}` +- ✅ 导出类接口保留文件流(`/api/v1/export`) +- ✅ 失败路径统一 JSON 错误 + +## Phase 2:模块管理后端 +- ✅ `GET /api/v1/modules` +- ✅ `POST /api/v1/modules/:module/toggle` +- ✅ toggle reason 必填 +- ✅ toggle 幂等 noop 返回 +- ✅ 关键模块保护:禁止禁用 `cpa` + +## Phase 3:Dashboard 聚合后端 +- ✅ `GET /api/v1/dashboard/overview` +- ✅ `GET /api/v1/dashboard/summary` +- ✅ jobs 列表多维筛选(见 Phase 4) + +## Phase 4:Ops Jobs 能力增强 +- ✅ `GET /api/v1/ops/jobs` 支持筛选: + - `limit/status/target/runbook/request_id/operator/risk_level/q/from/to` +- ✅ `GET /api/v1/ops/jobs/request/:requestID`(含 `total`) +- ✅ `GET /api/v1/ops/jobs/:id`(`step_stats/step_total/duration`) +- ✅ `POST /api/v1/ops/jobs/:id/cancel`(reason 必填,状态校验) +- ✅ `POST /api/v1/ops/jobs/:id/retry`(reason 必填,仅 failed) + +## Phase 5:策略与风控中心化(阶段性) +- ✅ `policy.CheckGate` 作为 gate 核心校验 +- ✅ 新增 `policy.ParseCommonFlags`,统一 `--dry-run/--confirm` +- ✅ CPA 移除重复 guard(不再分散解析) +- ✅ CF/Mail 切到 Runner + Gate + Runbook 模式 +- ⏳ 待补:更细粒度 permission/gate 模板化(跨模块统一声明) + +## Phase 6:兼容层收缩与下线准备 +- ✅ legacy 路由包装审计: + - `/api/records` + - `/delete/:id` + - `/export` +- ✅ 访问 legacy 自动写审计:`legacy.route.access` +- ✅ legacy deprecated 响应头: + - `X-API-Deprecated: true` + - `X-API-Replacement` + - `Warning: 299 ...` +- ✅ 迁移观测接口: + - `GET /api/v1/admin/legacy/usage` + - `GET /api/v1/admin/legacy/trend?days=...` + - `GET /api/v1/admin/legacy/readiness?days=...&zero_days=...` + +--- + +## 仍在进行 / 建议下一步 + +1) Policy 进一步模板化 +- 将 `NeedFlag/RequireConfirm/AllowDryRun` 配置抽成模块命令描述,减少模块内手写。 + +2) Channel Adapter 统一化 +- TG/QQ/Feishu 进一步收敛到 `core/ports/channel.go` 统一适配层。 + +3) Legacy 软下线流程 +- 当 `readiness.ready=true` 连续多日后,先灰度关闭 legacy,再完全移除路由映射。 + +4) 发布前整理 +- 清理工作区改动并提交;执行 `make release-check`。 + +--- + +## 关键验证 + +- 最近多轮 `go build ./cmd/main.go` 均通过(仅 gojieba C++ deprecation warning,非阻塞)。 +- 双服务并存策略维持不变:`xiaji-go` 与 `ops-assistant` 并行。 diff --git a/docs/cf-commands-design.md b/docs/cf-commands-design.md new file mode 100644 index 0000000..0b001ca --- /dev/null +++ b/docs/cf-commands-design.md @@ -0,0 +1,123 @@ +# CF 命令设计(ops-assistant v1) + +## 目标 +为 ops-assistant 提供 Cloudflare DNS 管理能力,保证幂等、安全、可审计。 + +--- + +## 1. 命令清单 + +- `/cf zones` + - 列出可用 Zone(name/id) + +- `/cf dnslist [name] [type]` + - 示例:`/cf dnslist example.com` + - 示例:`/cf dnslist example.com www A` + +- `/cf dnsadd [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]` + - 示例:`/cf dnsadd test.fnos.xx.kg 1.2.3.4` + +- `/cf dnsset [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]` + - 存在则更新,不存在则创建 + +- `/cf dnsdel --type ` + - **必须带 `--type`** + - **需二次确认 `YES`** + +- `/cf dnsproxy on|off` + - 仅更新 proxied + +- `/cf health` + - Token/权限可用性检查 + +--- + +## 2. 权限与安全 + +- `read` 角色:仅允许 `zones / dnslist / health` +- `admin` 角色:允许所有命令 +- `dnsdel` 必须二次确认 `YES` + +--- + +## 3. 参数校验 + +- `fqdn` 必须属于某个 Zone(`.`) +- `ip` 校验: + - `A` → IPv4 + - `AAAA` → IPv6 +- `type` 默认 `A` +- `ttl` 默认 `120` +- `proxied` 默认保持原值(仅 dnsproxy/dnsset 明确修改) + +--- + +## 4. 幂等与更新规则 + +### `/cf dnsadd` +1) 查询同名同类型记录 +2) 存在 → 返回“已存在” +3) 不存在 → 创建 + +### `/cf dnsset` +- 若任一字段不同则更新: + - `content` + - `ttl` + - `proxied` +- content 相同但 ttl/proxied 不同 → 仍执行更新 + +### `/cf dnsdel` +- 必须带 `--type` +- 仅删除指定 type 记录 + +--- + +## 5. 输出规范 + +### 成功 +``` +✅ DNS操作成功 +- name: +- type: +- content: +- ttl: +- proxied: +- id: +``` + +### 已存在 +``` +ℹ️ DNS记录已存在(未变更) +- name: +- type: +- content: +- id: +``` + +### 失败 +``` +❌ DNS操作失败 +- reason: <鉴权/参数/CF错误> +- detail: +``` + +--- + +## 6. 审计日志(必写) + +字段: +- `ts / actor / cmd / zone / fqdn / action / record_id / result / detail` + +示例: +```json +{ + "ts": "2026-03-14T15:02:10+08:00", + "actor": "kory", + "cmd": "/cf dnsadd test.fnos.xx.kg 1.2.3.4", + "zone": "fnos.xx.kg", + "record_id": "xxxxx", + "action": "create", + "result": "ok", + "detail": {"type":"A","ttl":120,"proxied":false} +} +``` diff --git a/docs/cf-dns-runbook-incident-20260313.md b/docs/cf-dns-runbook-incident-20260313.md new file mode 100644 index 0000000..bd6c6f8 --- /dev/null +++ b/docs/cf-dns-runbook-incident-20260313.md @@ -0,0 +1,39 @@ +# CF DNS Runbook 事故复盘(2026-03-13) + +## 背景 +在 ops-assistant 中新增 CF 模块命令(/cf dnsadd/dnsset/dnsdel/dnsproxy)并进行自测时,出现多次失败与反复修复,导致进度延误。 + +## 现象 +- `/cf dnsadd test.fnos.xx.kg 127.0.0.1` 连续失败多次。 +- Cloudflare API 返回 `Invalid request headers`。 +- runbook 步骤日志只回显脚本文本,未见真实 API 返回。 + +## 根因 +1) **认证方式误判** +- 将 API Token 误当作 API Key,使用了 `X-Auth-Email/X-Auth-Key` 方式,导致 CF 返回 `Invalid request headers`。 +- 修复后改回 Bearer 才验证 token 可用。 + +2) **runbook 执行方式不稳定(ssh heredoc/转义)** +- runbook 使用 `ssh.exec` 远端执行,命令通过 `bash -lc` + `ssh "..."` 传递。 +- heredoc 在多层转义/引用中被破坏,脚本未执行或被 shell 解释掉,stderr 只回显脚本内容。 +- `python3 -c` 内联也因换行/转义导致 `SyntaxError`。 + +## 卡点分析 +- **卡点本质不是 Token**,而是 **远端执行链路中的脚本传递/转义失效**。 +- 缺少一条稳定的“文本脚本传递”方式,导致同一命令多次失败、定位耗时。 + +## 最终解决 +- 改为 **base64 + python3 -c exec(...)** 的方式传递脚本,避免多层引用与换行问题。 +- `/cf dnsadd test.fnos.xx.kg 127.0.0.1` 成功创建记录。 + - record id: `acd49e048bc74c1b16d935b69f5aac54` + - job: `27` + +## 经验与改进 +1) **认证方式先验确认**:明确 Token vs Key 的调用差异。 +2) **避免 heredoc 跨 ssh**:优先 `base64|python3` 或 `python3 -c` 单行命令。 +3) **失败输出必须可见**:stderr/stdout 要确保能返回实际 API 响应,禁止“只回显脚本文本”。 +4) **阶段性汇报必须带证据**:无日志/产物不使用“完成/已执行”。 + +## 待办 +- 将 dnsset/dnsdel/dnsproxy 全部切换为稳定执行方式(base64 + python3 -c)。 +- 评估是否引入 `local.exec`,避免本地项目走 ssh 远端执行。 diff --git a/docs/channel-adapter-template.md b/docs/channel-adapter-template.md new file mode 100644 index 0000000..471d9e9 --- /dev/null +++ b/docs/channel-adapter-template.md @@ -0,0 +1,28 @@ +# Channel Adapter Template + +目标:新通道只做 I/O 适配,不耦合业务执行。 + +## 责任边界 + +- 接收平台消息并标准化为统一结构 +- 调用 `ops.Service.Handle(...)` 处理命令 +- 将文本回复回传到平台 + +## 禁止事项 + +- 不在通道内直接执行 shell/ssh/http 运维动作 +- 不在通道内做模块业务判断(应交给 command/module) + +## 最小流程 + +1. `Normalize(raw)` -> `UnifiedMessage` +2. `handled, out := opsSvc.Handle(msg.OperatorID, msg.Text)` +3. `if handled { Reply(out) }` +4. 未处理消息再回退到历史业务逻辑(如记账) + +## 接入检查 + +- [ ] 仅实现消息适配与回复 +- [ ] 已接入 opsSvc.Handle +- [ ] 未绕过 policy/runbook +- [ ] 错误信息对用户可读 diff --git a/docs/frontend-api-handoff.md b/docs/frontend-api-handoff.md new file mode 100644 index 0000000..e3618d0 --- /dev/null +++ b/docs/frontend-api-handoff.md @@ -0,0 +1,491 @@ +# Ops-Assistant 前端对接文档(给 Gemini) + +> 目的:让前端可独立完成 ops-assistant 新后台页面,不依赖旧 xiaji 记账页面心智。 +> 基址:同源(已登录后直接请求 `/api/v1/*`) +> 鉴权:Cookie Session(`ops_user` + `ops_token`) + +--- + +## 0. 统一响应约定(必须) + +### 成功 +```json +{ + "code": "OK", + "message": "ok", + "data": { ... } +} +``` + +### 失败 +```json +{ + "code": "ERR_XXX", + "message": "可读错误信息" +} +``` + +### 前端统一处理建议 +- `if (!res.ok)`: 显示 `message` +- `if (res.ok)`: 使用 `data` +- 不再使用 `error/status` 老字段 + +> 例外:`GET /api/v1/export` 成功是文件流下载,不是 JSON。 + +--- + +## 1. 权限与用户信息 + +### 1.1 获取当前用户 +**GET** `/api/v1/me` + +### data 字段 +- `username` +- `role` +- `user_id` +- `permissions[]` +- `flags{}` +- `effective_capabilities{}` + +### capability 常用键 +- `can_view_ops` +- `can_cancel_ops` +- `can_retry_ops` +- `can_view_flags` +- `can_edit_flags` +- `can_view_channels` +- `can_edit_channels` +- `can_test_channels` +- `can_view_audit` + +前端应以 capability 驱动按钮显隐。 + +--- + +## 2. Dashboard(新后台首页主数据) + +### 2.1 获取总览 +**GET** `/api/v1/dashboard/overview` + +### 返回 data +```json +{ + "jobs": { + "recent": [OpsJob...], + "status_count": { + "pending": 0, + "running": 0, + "success": 0, + "failed": 0, + "cancelled": 0 + } + }, + "modules": [ + {"module":"cpa","enabled":true}, + {"module":"cf","enabled":false}, + {"module":"mail","enabled":false} + ], + "channels": [ + {"platform":"telegram","enabled":true,"status":"ok"} + ] +} +``` + +### 2.2 获取轻量摘要(推荐首页首屏) +**GET** `/api/v1/dashboard/summary` + +### 返回 data +```json +{ + "jobs": { + "total": 120, + "running": 2, + "failed": 5, + "success": 98 + }, + "modules": { + "cpa": true, + "cf": false, + "mail": false + } +} +``` + +--- + +## 3. 模块管理(Module Center) + +### 3.1 模块列表 +**GET** `/api/v1/modules` + +### 返回 data +```json +{ + "modules": [ + { + "module":"cpa", + "display_name":"CPA 管理", + "flag_key":"enable_module_cpa", + "enabled":true + } + ] +} +``` + +### 3.2 切换模块开关 +**POST** `/api/v1/modules/:module/toggle` + +- `:module` 仅支持:`cpa|cf|mail` + +### body +```json +{ + "enabled": true, + "reason": "前端联调启用" +} +``` + +### 成功 data +```json +{ + "module": "cf", + "flag_key": "enable_module_cf", + "old": false, + "new": true +} +``` + +### 交互建议 +- 操作前确认弹窗 +- 必填 reason +- 成功后刷新 `/api/v1/modules` 与 `/api/v1/dashboard/overview` + +--- + +## 4. Ops 任务中心 + +### 4.1 任务列表 +**GET** `/api/v1/ops/jobs?limit=50` + +支持筛选 query: +- `status`(pending|running|success|failed|cancelled) +- `target`(如 hwsg) +- `runbook`(如 cpa_usage_backup) +- `request_id` +- `operator`(操作者 user_id,int64) +- `risk_level`(low|medium|high 等) +- `q`(模糊检索,命中 command/runbook/target/request_id) +- `from`(RFC3339,按 created_at 下界过滤) +- `to`(RFC3339,按 created_at 上界过滤) + +### data +```json +{ + "jobs": [OpsJob...], + "filters": { + "limit": 50, + "status": "failed", + "target": "hwsg", + "runbook": "cpa_usage_restore", + "request_id": "ops-u1-1741563000", + "operator": "1", + "risk_level": "high", + "q": "restore", + "from": "2026-03-10T07:00:00+08:00", + "to": "2026-03-10T08:00:00+08:00" + } +} +``` + +### 4.2 按 request_id 反查任务 +**GET** `/api/v1/ops/jobs/request/:requestID?limit=50` + +### data +```json +{ + "request_id":"req-20260310-abc", + "total": 3, + "jobs":[OpsJob...] +} +``` + +### 4.3 任务详情(含步骤 + 聚合统计) +**GET** `/api/v1/ops/jobs/:id` + +### data +```json +{ + "job": { ... }, + "steps": [ + { + "step_id":"...", + "action":"ssh.exec", + "status":"success|failed|running", + "stdout_tail":"...", + "stderr_tail":"..." + } + ], + "step_stats": { + "running": 0, + "success": 3, + "failed": 1, + "skipped": 0 + }, + "step_total": 4, + "duration": { + "job_ms": 5321, + "steps_ms_sum": 4870 + } +} +``` + +### 4.4 取消任务 +**POST** `/api/v1/ops/jobs/:id/cancel` + +body: +```json +{"reason":"人工终止,参数配置错误"} +``` + +### data +```json +{"id":123,"reason":"人工终止,参数配置错误"} +``` + +### 4.5 重试任务 +**POST** `/api/v1/ops/jobs/:id/retry` + +body: +```json +{"reason":"修复配置后重试"} +``` + +### data +```json +{"old_job_id":123,"new_job_id":456,"reason":"修复配置后重试"} +``` + +### 前端权限 +- cancel 按钮:`can_cancel_ops` +- retry 按钮:`can_retry_ops` + +### 交互约束 +- cancel/retry 必须填写 reason(后端强校验) +- retry 仅允许 failed 状态任务 +- cancel 仅允许 pending/running 状态任务 +- `from/to` 参数必须是 RFC3339(如 `2026-03-10T07:00:00+08:00`) + +--- + +## 5. 通道管理(Channels) + +### 5.1 通道列表 +**GET** `/api/v1/admin/channels` + +### data +```json +{ + "channels": [ + { + "platform":"telegram", + "name":"Telegram Bot", + "enabled": true, + "status":"ok", + "config_json":"{}", + "draft_config_json":"{}", + "secrets":"{\"token\":\"***\"}", + "draft_secrets":"{}", + "has_draft": false + } + ] +} +``` + +### 5.2 更新草稿 +**PATCH** `/api/v1/admin/channels/:platform` + +body 可包含: +- `name` +- `enabled` +- `config` +- `secrets` + +### 5.3 发布草稿 +**POST** `/api/v1/admin/channels/:platform/publish` + +### 5.4 热加载 +**POST** `/api/v1/admin/channels/reload` + +### 5.5 一键全禁用 +**POST** `/api/v1/admin/channels/disable-all` + +### 5.6 启用/禁用单通道 +- **POST** `/api/v1/admin/channels/:platform/enable` +- **POST** `/api/v1/admin/channels/:platform/disable` + +### 5.7 连通性测试 +**POST** `/api/v1/admin/channels/:platform/test` + +### 5.8 原子应用(patch + publish + reload) +**POST** `/api/v1/admin/channels/:platform/apply` + +> apply 失败 message 中可能包含 stage 信息(patch/publish/reload)。 + +--- + +## 6. 审计日志 + +### 6.1 查询审计 +**GET** `/api/v1/admin/audit` + +支持 query: +- `action` +- `target_type` +- `result` +- `actor_id` +- `from` (RFC3339) +- `to` (RFC3339) +- `limit` (默认100,最大500) +- `offset` + +### data +```json +{ "logs": [AuditLog...] } +``` + +--- + +## 7. 风险开关(Flags) + +### 7.1 列表 +**GET** `/api/v1/admin/settings/flags` + +### data +```json +{ "flags": [FeatureFlag...] } +``` + +### 7.2 修改 +**PATCH** `/api/v1/admin/settings/flags/:key` + +body: +```json +{ "enabled": true, "reason": "..." } +``` + +> 部分 flag `RequireReason=true`,reason 为空会失败。 + +--- + +## 8. 兼容层(前端新代码不要用) + +以下仅兼容旧页面: +- `GET /api/records` +- `POST /delete/:id` +- `GET /export` + +新前端一律使用 `/api/v1/*`。 + +### 8.1 Legacy 使用统计(用于迁移观察) +**GET** `/api/v1/admin/legacy/usage` + +### data +```json +{ + "summary": [ + {"route":"/api/records","count":12}, + {"route":"/delete/:id","count":3}, + {"route":"/export","count":1} + ], + "recent": [AuditLog...] +} +``` + +### 8.2 Legacy 调用趋势(按天) +**GET** `/api/v1/admin/legacy/trend?days=7` + +- `days` 范围:`1~90`,默认 `7` + +### data +```json +{ + "days": 7, + "from": "2026-03-04T00:00:00+08:00", + "trends": [ + { + "route": "/api/records", + "points": [ + {"day":"2026-03-04","count":2}, + {"day":"2026-03-05","count":1} + ] + } + ] +} +``` + +### 8.3 Legacy 响应头(便于前端观察迁移) +访问旧路由时,响应头会包含: +- `X-API-Deprecated: true` +- `X-API-Replacement: <对应新路由>` +- `Warning: 299 - "legacy API deprecated, use ..."` + +### 8.4 Legacy 下线就绪评估 +**GET** `/api/v1/admin/legacy/readiness?days=7&zero_days=3` + +- `days`:观察窗口(1~90,默认 7) +- `zero_days`:要求连续 0 调用天数(1~30,默认 3) + +### data +```json +{ + "days": 7, + "zero_days": 3, + "window_total": 0, + "route_totals": { + "/api/records": 0, + "/delete/:id": 0, + "/export": 0 + }, + "consecutive_zero_days": { + "/api/records": 7, + "/delete/:id": 7, + "/export": 7 + }, + "ready": true, + "recommendation": "可考虑下线 legacy 路由(已满足连续0调用阈值)" +} +``` + +--- + +## 9. 前端落地建议(给 Gemini) + +1. 建立统一 `apiClient`: + - `request(url, options)` + - 自动解析 `{code,message,data}` + - 非 2xx 抛 message + +2. 页面优先级: + - P1:Dashboard(overview + summary) + - P1:Ops Jobs + - P1:Modules + - P2:Channels + - P2:Audit + +3. 权限驱动 UI: + - 所有按钮显隐由 `effective_capabilities` 控制 + +4. 状态刷新策略: + - dashboard 每 10~20s 轮询 + - ops jobs running 状态 5~10s 轮询 + +5. 错误展示: + - toast + message 文本 + - 不展示后端堆栈 + +--- + +## 10. 已知限制 + +- export 成功仍为文件流(设计如此)。 +- gojieba 编译 warning 可忽略(非功能错误)。 diff --git a/docs/frontend-joint-checklist.md b/docs/frontend-joint-checklist.md new file mode 100644 index 0000000..9b6a4c5 --- /dev/null +++ b/docs/frontend-joint-checklist.md @@ -0,0 +1,98 @@ +# Ops-Assistant 前后端联调检查清单(给前端/Gemini) + +更新日期:2026-03-10 + +--- + +## A. 统一基础 + +- [ ] 所有请求走 `/api/v1/*`(禁用新代码调用 legacy 路由) +- [ ] `apiClient` 统一解析 `{code,message,data}` +- [ ] 全局错误 toast 仅展示 `message` +- [ ] 鉴权失败统一跳转登录 + +--- + +## B. 登录后初始化 + +- [ ] 调 `GET /api/v1/me` +- [ ] 用 `effective_capabilities` 控制页面和按钮显隐 +- [ ] 没有权限时不渲染可操作按钮 + +--- + +## C. Dashboard + +- [ ] 首屏并行请求: + - `GET /api/v1/dashboard/summary` + - `GET /api/v1/dashboard/overview` +- [ ] 状态卡片展示 jobs 统计、模块状态、通道状态 +- [ ] 10~20 秒轮询刷新 + +--- + +## D. Modules 页面 + +- [ ] 列表:`GET /api/v1/modules` +- [ ] 开关:`POST /api/v1/modules/:module/toggle` + - [ ] reason 必填 + - [ ] 处理 `message=noop` + - [ ] cpa 禁用失败提示 +- [ ] 成功后刷新 modules + overview + +--- + +## E. Ops Jobs 页面 + +- [ ] 列表:`GET /api/v1/ops/jobs` +- [ ] 支持筛选字段: + - [ ] status + - [ ] target + - [ ] runbook + - [ ] request_id + - [ ] operator + - [ ] risk_level + - [ ] q + - [ ] from/to(RFC3339) +- [ ] 展示后端回显的 `filters` +- [ ] 详情:`GET /api/v1/ops/jobs/:id` + - [ ] step_stats + - [ ] step_total + - [ ] duration.job_ms / duration.steps_ms_sum +- [ ] request_id 反查:`GET /api/v1/ops/jobs/request/:requestID` +- [ ] cancel/retry: + - [ ] reason 必填 + - [ ] 按权限按钮显隐(can_cancel_ops / can_retry_ops) + +--- + +## F. Channels 页面 + +- [ ] `GET /api/v1/admin/channels` +- [ ] patch/publish/reload/apply 流程联通 +- [ ] secrets 脱敏显示并正确提交 + +--- + +## G. Audit 页面 + +- [ ] `GET /api/v1/admin/audit` +- [ ] 支持 from/to、action、target_type、result、actor_id + +--- + +## H. Legacy 迁移看板(管理页) + +- [ ] `GET /api/v1/admin/legacy/usage` +- [ ] `GET /api/v1/admin/legacy/trend?days=7` +- [ ] `GET /api/v1/admin/legacy/readiness?days=7&zero_days=3` +- [ ] 显示 `ready` + `recommendation` + +--- + +## I. 验收标准 + +- [ ] 前端无新代码依赖 legacy 路由 +- [ ] 所有核心页面可在 ops-assistant 独立运行 +- [ ] 权限控制、错误处理、轮询刷新行为正常 +- [ ] readiness 达标后可计划 legacy 下线窗口 diff --git a/docs/module-adapter-template.md b/docs/module-adapter-template.md new file mode 100644 index 0000000..4d3cd92 --- /dev/null +++ b/docs/module-adapter-template.md @@ -0,0 +1,37 @@ +# Module Adapter Template + +目标:新功能模块以最小改动接入 `ops-assistant`。 + +## 必备文件 + +- `internal/module//module.go` +- `runbooks/_*.yaml` + +## 推荐实现 + +1. 在 `module.go` 内解析命令前缀(如 `/cf`) +2. 构建 `core/module.Request` +3. 统一调用 `core/module.Runner.Run(...)` +4. 所有 gate(flag/confirm/dry-run)通过 `Request.Gate` 声明 + +## 最小示例(伪代码) + +```go +req := coremodule.Request{ + RunbookName: "cf_dns_upsert", + Inputs: map[string]string{"zone": zone, "name": name}, + Meta: runbook.RunMeta{Target:"cf", RiskLevel:"medium"}, + Gate: coremodule.Gate{NeedFlag:"allow_cf_write", RequireConfirm:true, ExpectedToken:"YES_CF", AllowDryRun:true}, + DryRun: dryRun, + ConfirmToken: confirm, +} +jobID, out, err := runner.Run(cmd.Raw, userID, req) +``` + +## 接入检查 + +- [ ] 命令不直接执行 shell/ssh +- [ ] 使用统一 Runner +- [ ] 风险操作有 Gate +- [ ] 返回 job_id 可追踪 +- [ ] runbook step 可审计 diff --git a/docs/multi-platform-channel-deploy.md b/docs/multi-platform-channel-deploy.md index eab019e..12f8e7c 100644 --- a/docs/multi-platform-channel-deploy.md +++ b/docs/multi-platform-channel-deploy.md @@ -1,4 +1,4 @@ -# Xiaji-Go 多平台渠道配置与回调部署说明 +# Ops-Assistant 多平台渠道配置与回调部署说明 ## 已支持平台 - 官方 QQ Bot(qqbot_official) diff --git a/docs/ops-assistant-v1.md b/docs/ops-assistant-v1.md new file mode 100644 index 0000000..163d480 --- /dev/null +++ b/docs/ops-assistant-v1.md @@ -0,0 +1,197 @@ +# Ops Assistant 独立项目 v1 方案 + +## 目标 +把高频运维动作做成**固定命令 + 固定流水线**,AI 只负责解释,不参与关键执行决策。 + +--- + +## v1 范围(先做) + +优先只落地 **CPA 管理中枢**: + +1. 固定命令入口(Telegram/QQ/飞书统一) +2. Runbook(YAML)确定性执行器 +3. 审计日志(步骤级) +4. 高风险能力默认关闭(Feature Flag) + +后续再扩展:Cloudflare DNS、邮箱转发。 + +--- + +## 目录结构(新增) + +```text +ops-assistant/ +├── internal/ +│ ├── core/ +│ │ ├── command/ # 命令解析 +│ │ ├── registry/ # 命令注册与路由 +│ │ ├── runbook/ # runbook 结构、执行器、锁、target 解析 +│ │ └── ops/ # ops 服务编排、重试 +│ └── module/ +│ └── cpa/ # CPA 模块命令与高风险闸门 +├── runbooks/ +│ ├── cpa_status.yaml +│ ├── cpa_usage_backup.yaml +│ └── cpa_usage_restore.yaml +└── docs/ + └── ops-assistant-v1.md +``` + +--- + +## 命令清单(v1) + +- `/cpa status` +- `/cpa usage backup` +- `/cpa usage restore ` + +### 约束 +- 仅允许上述命令(严格白名单) +- 每个命令映射到唯一 runbook +- 不支持自由 shell 指令输入 + +--- + +## Runbook DSL(v1) + +仅支持这些动作: + +- `ssh.exec`:远程执行固定命令 +- `http.get` / `http.post`:调用固定接口 +- `file.upload`:上传压缩包到 staging +- `file.extract`:解压到 staging +- `assert.json`:断言字段值 +- `sleep`:延迟 + +每个步骤必须有: + +- `id` +- `action` +- `on_fail`(`stop` | `continue`) + +所有变量只能来自: + +- `inputs.*`(命令参数) +- `env.*`(服务端配置) +- `steps..output.*`(前置步骤输出) + +--- + +## 审计与可追溯 + +建议新增两张表: + +### 1) `ops_jobs` +- `id` +- `command` +- `runbook` +- `operator_id` +- `status`(pending/running/success/failed) +- `started_at` +- `ended_at` + +### 2) `ops_job_steps` +- `id` +- `job_id` +- `step_id` +- `action` +- `status` +- `rc` +- `stdout_tail` +- `stderr_tail` +- `started_at` +- `ended_at` + +> 所有 Bot 命令回执都返回 `job_id`,便于追踪。 + +--- + +## 风险控制 + +- 默认 `dry-run=true`(先演练) +- 高风险步骤(restore/import)必须: + - 双确认(命令 + confirm token) + - feature flag 开启才允许执行 +- 审计日志不写明文 secrets +- 全部 secrets 走现有 `channel` 加密机制存储 + +--- + +## 与现有系统的集成点(独立项目并存) + +1. **Bot 层**:在 Telegram/QQ/飞书消息处理中增加 `/cpa` 命令分流 +2. **Web 层**:新增 `/ops` 页面查看任务状态与步骤日志 +3. **模型层**:新增 `ops_jobs` / `ops_job_steps` +4. **配置层**:增加 `ops.targets`(如 hwsg/wjynl) + +--- + +## 阶段性进度汇报原则(必须遵守) + +**模板(四要素)** +1) 阶段名(设计/实现/自测/上线等) +2) 已执行动作(具体到做了什么) +3) 可验证证据(日志、产物路径、返回码、截图等) +4) 下一步与前置条件(还差什么、需谁确认) + +**硬规则** +- 无证据不使用“完成/已执行/进行中”等措辞 +- 遇到卡点必须立即说明:卡点 + 原因 + 需要的唯一输入 +- 先清单后执行的任务,未确认不得执行 + +--- + +## 事故复盘(2026-03-13,CF DNS Runbook) + +详见:`docs/cf-dns-runbook-incident-20260313.md` + +要点摘要: +- 认证方式误判(API Token 被当作 API Key)。 +- heredoc/转义在 ssh.exec 中导致脚本未执行。 +- 最终采用 base64 + python3 -c exec 稳定执行。 +- 强化“阶段性汇报必须带证据”的纪律。 + +--- + +## 验收标准(v1) + +1. `/cpa status` 能返回结构化结果(非自由文本) +2. `/cpa usage backup` 能输出:备份路径 + sha256 + usage 快照 +3. `/cpa usage restore ` 支持双校验(立即 + 延迟) +4. 任一步骤失败时可追溯到具体 step 日志 +5. 未授权命令必须被拒绝并记录审计 + +--- + +## 当前已落地(2026-03-10) + +1. 已打通命令入口:Telegram / QQ / 飞书 +2. 已支持命令: + - `/cpa status` + - `/cpa usage backup` + - `/cpa usage restore ` +3. 已提供查询接口: + - `GET /api/v1/ops/jobs` + - `GET /api/v1/ops/jobs/:id`(含 steps) +4. `assert.json` 已支持真实 JSON 路径断言 +5. 已增加安全闸门: + - `allow_ops_restore` feature flag(默认 false) + - restore 需要 `--confirm YES_RESTORE` + - 支持 `--dry-run` +6. 已支持 `ops_targets` 目标主机表(优先解析 target 名称) + +## 当前 Core 强化(2026-03-10 第二阶段) + +1. 同 target 串行锁(避免并发覆盖) +2. 作业元信息增强:`target/risk_level/request_id/confirm_hash` +3. 统一错误码前缀(如 `ERR_FEATURE_DISABLED` / `ERR_CONFIRM_REQUIRED` / `ERR_STEP_FAILED`) +4. step 超时控制(默认 45s) +5. 任务取消接口:`POST /api/v1/ops/jobs/:id/cancel` + +## 下一步落地建议 + +1. 为取消操作增加权限细分(`ops.cancel`) +2. 增加 job 重试接口(仅失败任务可重试) +3. 增加步骤级超时配置(runbook 可覆盖) +4. 增加 Cloudflare / Mail 模块(在 Core 验收完成后) diff --git a/go.mod b/go.mod index 4958cb8..552d70c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module xiaji-go +module ops-assistant go 1.22 diff --git a/internal/bot/telegram.go b/internal/bot/telegram.go index d305e03..7c4afcd 100644 --- a/internal/bot/telegram.go +++ b/internal/bot/telegram.go @@ -7,9 +7,10 @@ import ( "strings" "time" - xchart "xiaji-go/internal/chart" - "xiaji-go/internal/service" - "xiaji-go/models" + xchart "ops-assistant/internal/chart" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/service" + "ops-assistant/models" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "gorm.io/gorm" @@ -22,14 +23,15 @@ type TGBot struct { api *tgbotapi.BotAPI finance *service.FinanceService db *gorm.DB + opsSvc *ops.Service } -func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService) (*TGBot, error) { +func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService, opsSvc *ops.Service) (*TGBot, error) { bot, err := tgbotapi.NewBotAPI(token) if err != nil { return nil, err } - return &TGBot{api: bot, finance: finance, db: db}, nil + return &TGBot{api: bot, finance: finance, db: db, opsSvc: opsSvc}, nil } func (b *TGBot) Start(ctx context.Context) { @@ -79,11 +81,21 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) { text := msg.Text chatID := msg.Chat.ID + if b.opsSvc != nil { + if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled { + m := tgbotapi.NewMessage(chatID, out) + if _, err := b.api.Send(m); err != nil { + log.Printf("发送OPS消息失败 chat=%d: %v", chatID, err) + } + return + } + } + var reply string switch { case text == "/start": - reply = "🦞 欢迎使用虾记记账!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助" + reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助" case text == "/help": reply = "📖 使用说明:\n\n直接发送带金额的文本即可自动记账。\n系统会自动识别金额和消费分类。\n\n支持格式:\n• 午饭 25元\n• ¥30 打车\n• 买水果15块\n\n命令:\n/list - 最近10条记录\n/today - 今日汇总\n/chart - 本月消费图表\n/week - 近7天每日趋势\n/start - 欢迎信息" diff --git a/internal/chart/chart.go b/internal/chart/chart.go index c06d8d7..389fe61 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -5,7 +5,7 @@ import ( "fmt" "math" - "xiaji-go/internal/service" + "ops-assistant/internal/service" "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" diff --git a/internal/core/ai/advisor.go b/internal/core/ai/advisor.go new file mode 100644 index 0000000..ad708db --- /dev/null +++ b/internal/core/ai/advisor.go @@ -0,0 +1,24 @@ +package ai + +type Mode string + +const ( + ModeOff Mode = "off" + ModeSuggest Mode = "suggest" + ModeExplain Mode = "explain" +) + +type Advisor interface { + Suggest(userInput string) (string, error) + Explain(result string) (string, error) +} + +type NoopAdvisor struct{} + +func (NoopAdvisor) Suggest(userInput string) (string, error) { + return "", nil +} + +func (NoopAdvisor) Explain(result string) (string, error) { + return "", nil +} diff --git a/internal/core/ai/client.go b/internal/core/ai/client.go new file mode 100644 index 0000000..b8c260b --- /dev/null +++ b/internal/core/ai/client.go @@ -0,0 +1,103 @@ +package ai + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +type Client struct { + BaseURL string + APIKey string + Model string + Timeout time.Duration +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature float64 `json:"temperature"` +} + +type chatResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (c *Client) Suggest(userInput string) (string, error) { + return c.chat(userInput) +} + +func (c *Client) Explain(result string) (string, error) { + return "", nil +} + +func commandGuide() string { + b, err := os.ReadFile("docs/ai_command_guide.md") + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func (c *Client) chat(userInput string) (string, error) { + if strings.TrimSpace(c.BaseURL) == "" || strings.TrimSpace(c.APIKey) == "" || strings.TrimSpace(c.Model) == "" { + return "", errors.New("ai config missing") + } + base := strings.TrimRight(c.BaseURL, "/") + url := base + "/chat/completions" + + sys := "你是命令翻译器。把用户的自然语言转换成系统支持的标准命令。只输出一行命令,不要解释。若无法确定,输出 FAIL。\n\n可用命令知识库:\n" + commandGuide() + "\n\n规则:严格按命令格式输出。缺少关键参数时输出 FAIL。不要猜测 zone_id/record_id/backup_id。" + req := chatRequest{ + Model: c.Model, + Messages: []chatMessage{ + {Role: "system", Content: sys}, + {Role: "user", Content: userInput}, + }, + Temperature: 0, + } + body, _ := json.Marshal(req) + client := &http.Client{Timeout: c.Timeout} + httpReq, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if resp.StatusCode == 429 { + return "", fmt.Errorf("ai rate limited") + } + return "", fmt.Errorf("ai http %d", resp.StatusCode) + } + var out chatResponse + if err := json.Unmarshal(b, &out); err != nil { + return "", err + } + if out.Error != nil && out.Error.Message != "" { + return "", errors.New(out.Error.Message) + } + if len(out.Choices) == 0 { + return "", errors.New("empty ai response") + } + return strings.TrimSpace(out.Choices[0].Message.Content), nil +} diff --git a/internal/core/ai/loader.go b/internal/core/ai/loader.go new file mode 100644 index 0000000..1347963 --- /dev/null +++ b/internal/core/ai/loader.go @@ -0,0 +1,40 @@ +package ai + +import ( + "strconv" + "strings" + "time" + + "ops-assistant/models" + + "gorm.io/gorm" +) + +func LoadClient(db *gorm.DB) *Client { + if db == nil { + return nil + } + get := func(key string) string { + var sset models.AppSetting + if err := db.Where("key = ?", key).First(&sset).Error; err == nil { + return strings.TrimSpace(sset.Value) + } + return "" + } + if strings.ToLower(get("ai_enabled")) != "true" { + return nil + } + baseURL := get("ai_base_url") + apiKey := get("ai_api_key") + model := get("ai_model") + to := 15 + if v := get("ai_timeout_seconds"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + to = n + } + } + if baseURL == "" || apiKey == "" || model == "" { + return nil + } + return &Client{BaseURL: baseURL, APIKey: apiKey, Model: model, Timeout: time.Duration(to) * time.Second} +} diff --git a/internal/core/command/parser.go b/internal/core/command/parser.go new file mode 100644 index 0000000..b2daeb4 --- /dev/null +++ b/internal/core/command/parser.go @@ -0,0 +1,67 @@ +package command + +import ( + "fmt" + "strings" +) + +type ParsedCommand struct { + Raw string + Name string + Args []string + Module string +} + +func Parse(raw string) (*ParsedCommand, error) { + text := strings.TrimSpace(raw) + if text == "" || !strings.HasPrefix(text, "/") { + return nil, fmt.Errorf("not a command") + } + parts := strings.Fields(text) + if len(parts) == 0 { + return nil, fmt.Errorf("empty command") + } + + cmd := &ParsedCommand{Raw: text, Name: parts[0]} + if len(parts) > 1 { + cmd.Args = parts[1:] + } + mod := strings.TrimPrefix(cmd.Name, "/") + if i := strings.Index(mod, "@"); i > 0 { + mod = mod[:i] + } + if mod != "" { + cmd.Module = mod + } + return cmd, nil +} + +// ParseWithInputs: 支持 /cf dns list 这种输入参数写入 runbook inputs +func ParseWithInputs(raw string) (*ParsedCommand, map[string]string, error) { + cmd, err := Parse(raw) + if err != nil { + return nil, nil, err + } + inputs := map[string]string{} + if cmd.Module == "cf" { + // /cf dns list + if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "list" && len(cmd.Args) >= 3 { + inputs["zone_id"] = cmd.Args[2] + } + // /cf dns update [ttl] [proxied] + if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "update" && len(cmd.Args) >= 7 { + inputs["zone_id"] = cmd.Args[2] + inputs["record_id"] = cmd.Args[3] + inputs["type"] = cmd.Args[4] + inputs["name"] = cmd.Args[5] + inputs["content"] = cmd.Args[6] + if len(cmd.Args) >= 8 { + inputs["ttl"] = cmd.Args[7] + } + if len(cmd.Args) >= 9 { + inputs["proxied"] = cmd.Args[8] + } + } + } + return cmd, inputs, nil +} diff --git a/internal/core/ecode/codes.go b/internal/core/ecode/codes.go new file mode 100644 index 0000000..9c1017f --- /dev/null +++ b/internal/core/ecode/codes.go @@ -0,0 +1,17 @@ +package ecode + +const ( + ErrPermissionDenied = "ERR_PERMISSION_DENIED" + ErrConfirmRequired = "ERR_CONFIRM_REQUIRED" + ErrFeatureDisabled = "ERR_FEATURE_DISABLED" + ErrStepFailed = "ERR_STEP_FAILED" + ErrJobCancelled = "ERR_JOB_CANCELLED" + ErrStepTimeout = "ERR_STEP_TIMEOUT" +) + +func Tag(code, msg string) string { + if code == "" { + return msg + } + return "[" + code + "] " + msg +} diff --git a/internal/core/module/request.go b/internal/core/module/request.go new file mode 100644 index 0000000..0837e0b --- /dev/null +++ b/internal/core/module/request.go @@ -0,0 +1,19 @@ +package module + +import "ops-assistant/internal/core/runbook" + +type Gate struct { + NeedFlag string + RequireConfirm bool + ExpectedToken string + AllowDryRun bool +} + +type Request struct { + RunbookName string + Inputs map[string]string + Meta runbook.RunMeta + Gate Gate + DryRun bool + ConfirmToken string +} diff --git a/internal/core/module/runner.go b/internal/core/module/runner.go new file mode 100644 index 0000000..2454f61 --- /dev/null +++ b/internal/core/module/runner.go @@ -0,0 +1,48 @@ +package module + +import ( + "fmt" + "strings" + + "gorm.io/gorm" + + "ops-assistant/internal/core/ecode" + "ops-assistant/internal/core/policy" + "ops-assistant/internal/core/runbook" +) + +type Runner struct { + db *gorm.DB + exec *runbook.Executor +} + +func NewRunner(db *gorm.DB, exec *runbook.Executor) *Runner { + return &Runner{db: db, exec: exec} +} + +func (r *Runner) Run(commandText string, operator int64, req Request) (uint, string, error) { + if strings.TrimSpace(req.RunbookName) == "" { + return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "runbook 不能为空")) + } + if req.DryRun { + if !req.Gate.AllowDryRun { + return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "当前命令不允许 dry-run")) + } + return 0, "dry-run", nil + } + if err := policy.CheckGate(r.db, policy.GateRequest{ + NeedFlag: req.Gate.NeedFlag, + RequireConfirm: req.Gate.RequireConfirm, + ConfirmToken: req.ConfirmToken, + ExpectedToken: req.Gate.ExpectedToken, + AllowDryRun: req.Gate.AllowDryRun, + DryRun: req.DryRun, + }); err != nil { + code := ecode.ErrFeatureDisabled + if strings.Contains(err.Error(), "confirm") || strings.Contains(err.Error(), "确认") { + code = ecode.ErrConfirmRequired + } + return 0, "", fmt.Errorf(ecode.Tag(code, err.Error())) + } + return r.exec.RunWithInputsAndMeta(commandText, req.RunbookName, operator, req.Inputs, req.Meta) +} diff --git a/internal/core/module/switches.go b/internal/core/module/switches.go new file mode 100644 index 0000000..c41ea96 --- /dev/null +++ b/internal/core/module/switches.go @@ -0,0 +1,26 @@ +package module + +import ( + "fmt" + "strings" + + "gorm.io/gorm" + + "ops-assistant/internal/core/policy" +) + +func switchFlag(module string) string { + module = strings.TrimSpace(strings.ToLower(module)) + if module == "" { + return "" + } + return fmt.Sprintf("enable_module_%s", module) +} + +func IsEnabled(db *gorm.DB, module string) bool { + k := switchFlag(module) + if k == "" { + return false + } + return policy.FlagEnabled(db, k) +} diff --git a/internal/core/module/template.go b/internal/core/module/template.go new file mode 100644 index 0000000..bf21fa8 --- /dev/null +++ b/internal/core/module/template.go @@ -0,0 +1,108 @@ +package module + +import ( + "fmt" + "strings" + "time" + + "ops-assistant/internal/core/policy" + "ops-assistant/internal/core/runbook" +) + +type CommandTemplate struct { + RunbookName string + Gate Gate + InputsFn func(text string, parts []string) (map[string]string, error) + MetaFn func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta + DryRunMsg string + SuccessMsg func(jobID uint) string + // ResultFormatter 可选:查询类命令用最终 stdout 格式化回传内容。 + // 返回 (消息, true) 表示使用格式化结果;返回 (anything, false) 回退到回执式消息。 + ResultFormatter func(out string) (string, bool) +} + +type CommandSpec struct { + Prefixes []string + Template CommandTemplate + ErrPrefix string + ErrHint string +} + +func ExecTemplate(runner *Runner, userID int64, raw string, tpl CommandTemplate) (uint, string, error) { + dryRun, confirmToken := policy.ParseCommonFlags(raw) + parts := strings.Fields(strings.TrimSpace(raw)) + inputs := map[string]string{} + if tpl.InputsFn != nil { + out, err := tpl.InputsFn(raw, parts) + if err != nil { + return 0, "", err + } + inputs = out + } + meta := runbook.NewMeta() + if tpl.MetaFn != nil { + meta = tpl.MetaFn(userID, confirmToken, inputs) + } + if meta.RequestID == "" { + meta.RequestID = fmt.Sprintf("ops-u%d-%d", userID, time.Now().Unix()) + } + req := Request{ + RunbookName: tpl.RunbookName, + Inputs: inputs, + Meta: meta, + Gate: tpl.Gate, + DryRun: dryRun, + ConfirmToken: confirmToken, + } + jobID, out, err := runner.Run(raw, userID, req) + if err != nil { + return jobID, out, err + } + if tpl.ResultFormatter != nil && out != "" && out != "dry-run" { + if msg, ok := tpl.ResultFormatter(out); ok { + return jobID, msg, nil + } + } + return jobID, out, err +} + +func FormatDryRunMessage(tpl CommandTemplate) string { + if tpl.DryRunMsg != "" { + return tpl.DryRunMsg + } + return fmt.Sprintf("🧪 dry-run: 将执行 %s(未实际执行)", tpl.RunbookName) +} + +func FormatSuccessMessage(tpl CommandTemplate, jobID uint) string { + if tpl.SuccessMsg != nil { + return tpl.SuccessMsg(jobID) + } + return fmt.Sprintf("✅ %s 已执行,job=%d", tpl.RunbookName, jobID) +} + +func MatchAnyPrefix(text string, prefixes []string) bool { + text = strings.TrimSpace(text) + for _, p := range prefixes { + if strings.HasPrefix(text, p) { + return true + } + } + return false +} + +func MatchCommand(text string, specs []CommandSpec) (CommandSpec, bool) { + for _, sp := range specs { + if MatchAnyPrefix(text, sp.Prefixes) { + return sp, true + } + } + return CommandSpec{}, false +} + +func FormatExecError(sp CommandSpec, err error) string { + msg := sp.ErrPrefix + err.Error() + if sp.ErrHint != "" { + msg += "(示例:" + sp.ErrHint + ")" + } + return msg +} diff --git a/internal/core/ops/bootstrap.go b/internal/core/ops/bootstrap.go new file mode 100644 index 0000000..fd8d809 --- /dev/null +++ b/internal/core/ops/bootstrap.go @@ -0,0 +1,32 @@ +package ops + +import ( + "path/filepath" + + "ops-assistant/internal/core/registry" + "ops-assistant/internal/core/runbook" + "ops-assistant/internal/module/cf" + "ops-assistant/internal/module/cfdav" + "ops-assistant/internal/module/cpa" + "ops-assistant/internal/module/mail" + "ops-assistant/internal/module/sub" + + "gorm.io/gorm" +) + +func BuildDefault(db *gorm.DB, dbPath, baseDir string) *Service { + r := registry.New() + exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks")) + cpaModule := cpa.New(db, exec) + cfModule := cf.New(db, exec) + mailModule := mail.New(db, exec) + cfdavModule := cfdav.New(db) + subModule := sub.New(db) + + r.RegisterModule("cpa", cpaModule.Handle) + r.RegisterModule("cf", cfModule.Handle) + r.RegisterModule("mail", mailModule.Handle) + r.RegisterModule("cfdav", cfdavModule.Handle) + r.RegisterModule("sub", subModule.Handle) + return NewService(dbPath, baseDir, r) +} diff --git a/internal/core/ops/run_once.go b/internal/core/ops/run_once.go new file mode 100644 index 0000000..7f5c925 --- /dev/null +++ b/internal/core/ops/run_once.go @@ -0,0 +1,20 @@ +package ops + +import ( + "path/filepath" + + "ops-assistant/internal/core/runbook" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// RunOnce executes a runbook directly without bot/channel. +func RunOnce(dbPath, baseDir, commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) { + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return 0, "", err + } + exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks")) + return exec.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, runbook.NewMeta()) +} diff --git a/internal/core/ops/service.go b/internal/core/ops/service.go index 7f5a219..6172826 100644 --- a/internal/core/ops/service.go +++ b/internal/core/ops/service.go @@ -58,22 +58,14 @@ func (s *Service) Handle(userID int64, text string) (bool, string) { func (s *Service) helpText() string { lines := []string{ - "🛠️ OPS 交互命令:", + "🛠️ OPS 帮助(模块入口):", "- /ops modules (查看模块启用状态)", - "- /cpa help", - "- /cpa status", - "- /cpa usage backup", - "- /cpa usage restore [--confirm YES_RESTORE] [--dry-run]", - "- /cf status (需要 enable_module_cf)", - "- /cf zones (需要 enable_module_cf)", - "- /cf dns list (需要 enable_module_cf)", - "- /cf dns update [ttl] [proxied:true|false] (需要 enable_module_cf)", - "- /cf dnsadd [on|off] [type] (需要 enable_module_cf)", - "- /cf dnsset [true] (需要 enable_module_cf)", - "- /cf dnsdel YES (需要 enable_module_cf)", - "- /cf dnsproxy on|off (需要 enable_module_cf)", - "- /cf workers list (需要 enable_module_cf)", - "- /mail status (需要 enable_module_mail)", + "- /cpa (CPA 使用统计/备份/恢复)", + "- /cf (Cloudflare 管理)", + "- /mail (邮箱状态)", + "- /cfdav (CFDAV 用户管理)", + "- /sub (订阅管理)", + "提示:发送 /cpa /cf /mail /cfdav /sub 获取各模块完整指令清单。", } return strings.Join(lines, "\n") } diff --git a/internal/core/policy/policy.go b/internal/core/policy/policy.go new file mode 100644 index 0000000..97bb2df --- /dev/null +++ b/internal/core/policy/policy.go @@ -0,0 +1,62 @@ +package policy + +import ( + "errors" + "strings" + + "gorm.io/gorm" + + "ops-assistant/models" +) + +type GateRequest struct { + NeedFlag string + RequireConfirm bool + ConfirmToken string + ExpectedToken string + AllowDryRun bool + DryRun bool +} + +func ParseCommonFlags(text string) (dryRun bool, confirmToken string) { + parts := strings.Fields(strings.TrimSpace(text)) + for i := 0; i < len(parts); i++ { + if parts[i] == "--dry-run" { + dryRun = true + } + if parts[i] == "--confirm" && i+1 < len(parts) { + confirmToken = strings.TrimSpace(parts[i+1]) + i++ + } + } + return +} + +func FlagEnabled(db *gorm.DB, key string) bool { + if strings.TrimSpace(key) == "" { + return true + } + var ff models.FeatureFlag + if err := db.Where("key = ?", key).First(&ff).Error; err != nil { + return false + } + return ff.Enabled +} + +func CheckGate(db *gorm.DB, req GateRequest) error { + if strings.TrimSpace(req.NeedFlag) != "" && !FlagEnabled(db, req.NeedFlag) { + return errors.New("feature flag 未启用: " + req.NeedFlag) + } + if req.RequireConfirm { + if strings.TrimSpace(req.ConfirmToken) == "" { + return errors.New("缺少 --confirm ") + } + if strings.TrimSpace(req.ExpectedToken) != "" && strings.TrimSpace(req.ConfirmToken) != strings.TrimSpace(req.ExpectedToken) { + return errors.New("确认 token 无效") + } + } + if req.DryRun && !req.AllowDryRun { + return errors.New("当前命令不允许 dry-run") + } + return nil +} diff --git a/internal/core/ports/channel.go b/internal/core/ports/channel.go new file mode 100644 index 0000000..c6de0cd --- /dev/null +++ b/internal/core/ports/channel.go @@ -0,0 +1,14 @@ +package ports + +type UnifiedMessage struct { + Channel string + OperatorID int64 + Text string + RawID string +} + +type ChannelAdapter interface { + Name() string + Normalize(any) (*UnifiedMessage, error) + Reply(targetID string, text string) error +} diff --git a/internal/core/ports/module.go b/internal/core/ports/module.go new file mode 100644 index 0000000..810e4b9 --- /dev/null +++ b/internal/core/ports/module.go @@ -0,0 +1,6 @@ +package ports + +type Module interface { + Name() string + CommandPrefix() string +} diff --git a/internal/core/registry/registry.go b/internal/core/registry/registry.go new file mode 100644 index 0000000..7d28bc3 --- /dev/null +++ b/internal/core/registry/registry.go @@ -0,0 +1,47 @@ +package registry + +import ( + "sort" + + "ops-assistant/internal/core/command" +) + +type Handler func(userID int64, cmd *command.ParsedCommand) (string, error) + +type Registry struct { + handlers map[string]Handler + moduleHandlers map[string]Handler +} + +func New() *Registry { + return &Registry{handlers: map[string]Handler{}, moduleHandlers: map[string]Handler{}} +} + +func (r *Registry) Register(name string, h Handler) { + r.handlers[name] = h +} + +func (r *Registry) RegisterModule(module string, h Handler) { + r.moduleHandlers[module] = h +} + +func (r *Registry) Handle(userID int64, cmd *command.ParsedCommand) (string, bool, error) { + if h, ok := r.handlers[cmd.Name]; ok { + out, err := h(userID, cmd) + return out, true, err + } + if h, ok := r.moduleHandlers[cmd.Module]; ok { + out, err := h(userID, cmd) + return out, true, err + } + return "", false, nil +} + +func (r *Registry) ListModules() []string { + mods := make([]string, 0, len(r.moduleHandlers)) + for m := range r.moduleHandlers { + mods = append(mods, m) + } + sort.Strings(mods) + return mods +} diff --git a/internal/core/runbook/executor.go b/internal/core/runbook/executor.go index b47742b..55b32d7 100644 --- a/internal/core/runbook/executor.go +++ b/internal/core/runbook/executor.go @@ -1,10 +1,15 @@ package runbook import ( + "archive/tar" + "archive/zip" "bytes" + "compress/gzip" "context" "encoding/json" "fmt" + "io" + "net/http" "os" "os/exec" "path/filepath" @@ -80,6 +85,7 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato outputs := map[string]string{} ctx := map[string]string{} + dataOut := "ok" jobCtx, jobCancel := context.WithCancel(context.Background()) registerJobCancel(job.ID, jobCancel) @@ -174,10 +180,13 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato _ = e.db.Save(&step).Error outputs[rendered.ID] = stdout ctx["steps."+rendered.ID+".output"] = stdout + if strings.TrimSpace(stdout) != "" && rendered.Action != "assert.json" { + dataOut = stdout + } } e.finishJob(&job, "success", "ok") - return job.ID, "ok", nil + return job.ID, dataOut, nil } func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) { @@ -230,6 +239,125 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string] } return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err + case "http.get": + url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"])) + if url == "" { + return 1, "", "missing url", fmt.Errorf("missing url") + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 1, "", err.Error(), err + } + applyHTTPHeaders(req, st.With["headers"]) + resp, err := http.DefaultClient.Do(req) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout")) + } + return 1, "", err.Error(), err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 1, strings.TrimSpace(string(body)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode) + } + return 0, strings.TrimSpace(string(body)), "", nil + + case "http.post": + url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"])) + if url == "" { + return 1, "", "missing url", fmt.Errorf("missing url") + } + bodyStr := "" + if b, ok := st.With["body"]; ok { + switch t := b.(type) { + case string: + bodyStr = t + default: + if jb, err := json.Marshal(t); err == nil { + bodyStr = string(jb) + } + } + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(bodyStr)) + if err != nil { + return 1, "", err.Error(), err + } + hasCT := applyHTTPHeaders(req, st.With["headers"]) + if bodyStr != "" && !hasCT { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout")) + } + return 1, "", err.Error(), err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 1, strings.TrimSpace(string(respBody)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode) + } + return 0, strings.TrimSpace(string(respBody)), "", nil + + case "file.upload": + source := strings.TrimSpace(fmt.Sprintf("%v", st.With["source"])) + stagingDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["staging_dir"])) + filename := strings.TrimSpace(fmt.Sprintf("%v", st.With["filename"])) + if source == "" { + return 1, "", "missing source", fmt.Errorf("missing source") + } + if stagingDir == "" { + stagingDir = "/tmp/ops-staging" + } + if filename == "" { + filename = filepath.Base(source) + } + if err := os.MkdirAll(stagingDir, 0o755); err != nil { + return 1, "", err.Error(), err + } + dest := filepath.Join(stagingDir, filepath.Base(filename)) + if err := copyFile(source, dest); err != nil { + return 1, "", err.Error(), err + } + return 0, dest, "", nil + + case "file.extract": + archivePath := strings.TrimSpace(fmt.Sprintf("%v", st.With["archive"])) + toDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["to_dir"])) + if archivePath == "" { + return 1, "", "missing archive", fmt.Errorf("missing archive") + } + if toDir == "" { + toDir = "/tmp/ops-staging/extracted" + } + if err := os.MkdirAll(toDir, 0o755); err != nil { + return 1, "", err.Error(), err + } + lower := strings.ToLower(archivePath) + switch { + case strings.HasSuffix(lower, ".zip"): + if err := extractZip(archivePath, toDir); err != nil { + return 1, "", err.Error(), err + } + case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"): + if err := extractTarGz(archivePath, toDir); err != nil { + return 1, "", err.Error(), err + } + case strings.HasSuffix(lower, ".tar"): + if err := extractTar(archivePath, toDir); err != nil { + return 1, "", err.Error(), err + } + default: + return 1, "", "unsupported archive format", fmt.Errorf("unsupported archive format: %s", archivePath) + } + return 0, toDir, "", nil + case "assert.json": sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"])) if sourceStep == "" { @@ -245,15 +373,27 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string] return 1, "", "invalid json", err } - rules := parseRequiredPaths(st.With["required_paths"]) - if len(rules) == 0 { - return 1, "", "required_paths empty", fmt.Errorf("required_paths empty") + expectSuccess := parseExpectSuccess(st.With["expect_success"]) + if expectSuccess { + v, ok := lookupPath(payload, "success") + if !ok { + return 1, "", "json path not found: success", fmt.Errorf("json path not found: success") + } + if !truthyValue(v) { + errSummary := summarizeJSONErrors(payload) + return 1, "", "api success=false: " + errSummary, fmt.Errorf("api success=false: %s", errSummary) + } } + + rules := parseRequiredPaths(st.With["required_paths"]) for _, p := range rules { if _, ok := lookupPath(payload, p); !ok { return 1, "", "json path not found: " + p, fmt.Errorf("json path not found: %s", p) } } + if len(rules) == 0 && !expectSuccess { + return 1, "", "required_paths empty", fmt.Errorf("required_paths empty") + } return 0, "assert ok", "", nil case "sleep": @@ -292,27 +432,33 @@ func renderStep(st Step, ctx map[string]string) Step { } m := make(map[string]any, len(out.With)) for k, v := range out.With { - switch t := v.(type) { - case string: - m[k] = renderString(t, ctx) - case []any: - arr := make([]any, 0, len(t)) - for _, it := range t { - if s, ok := it.(string); ok { - arr = append(arr, renderString(s, ctx)) - } else { - arr = append(arr, it) - } - } - m[k] = arr - default: - m[k] = v - } + m[k] = renderAny(v, ctx) } out.With = m return out } +func renderAny(v any, ctx map[string]string) any { + switch t := v.(type) { + case string: + return renderString(t, ctx) + case []any: + arr := make([]any, 0, len(t)) + for _, it := range t { + arr = append(arr, renderAny(it, ctx)) + } + return arr + case map[string]any: + m := make(map[string]any, len(t)) + for k, v := range t { + m[k] = renderAny(v, ctx) + } + return m + default: + return v + } +} + func renderString(s string, ctx map[string]string) string { res := s for k, v := range ctx { @@ -321,6 +467,158 @@ func renderString(s string, ctx map[string]string) string { return res } +func applyHTTPHeaders(req *http.Request, raw any) bool { + hasContentType := false + m, ok := raw.(map[string]any) + if !ok { + return hasContentType + } + for k, v := range m { + key := strings.TrimSpace(k) + if key == "" { + continue + } + val := strings.TrimSpace(fmt.Sprintf("%v", v)) + if val == "" { + continue + } + req.Header.Set(key, val) + if strings.EqualFold(key, "Content-Type") { + hasContentType = true + } + } + return hasContentType +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + fi, err := in.Stat() + if err != nil { + return err + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("source is not regular file") + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +func extractZip(src, dst string) error { + zr, err := zip.OpenReader(src) + if err != nil { + return err + } + defer zr.Close() + for _, f := range zr.File { + clean := filepath.Clean(f.Name) + if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { + return fmt.Errorf("invalid zip entry path: %s", f.Name) + } + target := filepath.Join(dst, clean) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.Create(target) + if err != nil { + rc.Close() + return err + } + _, cpErr := io.Copy(out, rc) + rc.Close() + if err := out.Close(); err != nil { + return err + } + if cpErr != nil { + return cpErr + } + } + return nil +} + +func extractTar(src, dst string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + return untar(f, dst) +} + +func extractTarGz(src, dst string) error { + f, err := os.Open(src) + if err != nil { + return err + } + defer f.Close() + gz, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gz.Close() + return untar(gz, dst) +} + +func untar(r io.Reader, dst string) error { + tr := tar.NewReader(r) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + clean := filepath.Clean(hdr.Name) + if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) { + return fmt.Errorf("invalid tar entry path: %s", hdr.Name) + } + target := filepath.Join(dst, clean) + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.Create(target) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + } + } + return nil +} + func parseRequiredPaths(v any) []string { res := []string{} switch t := v.(type) { @@ -363,6 +661,51 @@ func lookupPath(root any, path string) (any, bool) { return cur, true } +func parseExpectSuccess(v any) bool { + switch t := v.(type) { + case bool: + return t + case string: + return strings.EqualFold(strings.TrimSpace(t), "true") + default: + return false + } +} + +func truthyValue(v any) bool { + switch t := v.(type) { + case bool: + return t + case string: + return strings.EqualFold(strings.TrimSpace(t), "true") + default: + return false + } +} + +// summarizeJSONErrors 从 Cloudflare 风格的 {"success":false,"errors":[...]} 中提取可读错误摘要 +func summarizeJSONErrors(payload any) string { + msgs := []string{} + if m, ok := payload.(map[string]any); ok { + if errs, ok := m["errors"].([]any); ok { + for _, e := range errs { + switch ev := e.(type) { + case map[string]any: + code := fmt.Sprintf("%v", ev["code"]) + msg := fmt.Sprintf("%v", ev["message"]) + msgs = append(msgs, code+" "+msg) + default: + msgs = append(msgs, fmt.Sprintf("%v", ev)) + } + } + } + } + if len(msgs) == 0 { + return "unknown error" + } + return strings.Join(msgs, "; ") +} + func (e *Executor) finishJob(job *models.OpsJob, status, summary string) { job.Status = status job.Summary = summary diff --git a/internal/core/runbook/lock.go b/internal/core/runbook/lock.go new file mode 100644 index 0000000..4556681 --- /dev/null +++ b/internal/core/runbook/lock.go @@ -0,0 +1,21 @@ +package runbook + +import ( + "sync" +) + +var globalTargetLocks sync.Map + +type targetLock struct { + mu sync.Mutex +} + +func acquireTargetLock(target string) func() { + if target == "" { + return func() {} + } + v, _ := globalTargetLocks.LoadOrStore(target, &targetLock{}) + lk := v.(*targetLock) + lk.mu.Lock() + return func() { lk.mu.Unlock() } +} diff --git a/internal/core/runbook/meta.go b/internal/core/runbook/meta.go new file mode 100644 index 0000000..d502cb5 --- /dev/null +++ b/internal/core/runbook/meta.go @@ -0,0 +1,23 @@ +package runbook + +import "time" + +type RunMeta struct { + Target string + RiskLevel string + RequestID string + ConfirmHash string + StepTimeoutMs int +} + +func NewMeta() RunMeta { + return RunMeta{RiskLevel: "low"} +} + +func (m RunMeta) timeoutOrDefault() time.Duration { + ms := m.StepTimeoutMs + if ms <= 0 { + ms = 45000 + } + return time.Duration(ms) * time.Millisecond +} diff --git a/internal/core/runbook/seed_targets.go b/internal/core/runbook/seed_targets.go new file mode 100644 index 0000000..2618ee7 --- /dev/null +++ b/internal/core/runbook/seed_targets.go @@ -0,0 +1,20 @@ +package runbook + +import ( + "ops-assistant/models" + + "gorm.io/gorm" +) + +func SeedDefaultTargets(db *gorm.DB) error { + defaults := []models.OpsTarget{ + {Name: "hwsg", Host: "10.2.3.11", Port: 22, User: "root", Enabled: true}, + {Name: "wjynl", Host: "66.235.105.208", Port: 22, User: "root", Enabled: true}, + } + for _, t := range defaults { + if err := db.Where("name = ?", t.Name).FirstOrCreate(&t).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/core/runbook/targets.go b/internal/core/runbook/targets.go index ac114e7..eba4445 100644 --- a/internal/core/runbook/targets.go +++ b/internal/core/runbook/targets.go @@ -35,3 +35,8 @@ func resolveTarget(db *gorm.DB, name string) ResolvedTarget { } return ResolvedTarget{Found: true, User: user, Host: host, Port: port} } + +// ResolveTargetForModule 供其他包(如 cf 模块格式化器)复用目标解析 +func ResolveTargetForModule(db *gorm.DB, name string) ResolvedTarget { + return resolveTarget(db, name) +} diff --git a/internal/core/runbook/types.go b/internal/core/runbook/types.go new file mode 100644 index 0000000..6f8f3d0 --- /dev/null +++ b/internal/core/runbook/types.go @@ -0,0 +1,24 @@ +package runbook + +import "gopkg.in/yaml.v3" + +type Spec struct { + Version int `yaml:"version"` + Name string `yaml:"name"` + Steps []Step `yaml:"steps"` +} + +type Step struct { + ID string `yaml:"id"` + Action string `yaml:"action"` + OnFail string `yaml:"on_fail"` + With map[string]any `yaml:"with"` +} + +func Parse(data []byte) (*Spec, error) { + var s Spec + if err := yaml.Unmarshal(data, &s); err != nil { + return nil, err + } + return &s, nil +} diff --git a/internal/feishu/feishu.go b/internal/feishu/feishu.go index 68c8cdf..eb28a34 100644 --- a/internal/feishu/feishu.go +++ b/internal/feishu/feishu.go @@ -9,9 +9,10 @@ import ( "strings" "time" - "xiaji-go/internal/channel" - "xiaji-go/internal/service" - "xiaji-go/models" + "ops-assistant/internal/channel" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/service" + "ops-assistant/models" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -23,16 +24,18 @@ const DefaultUserID int64 = 1 type Bot struct { db *gorm.DB finance *service.FinanceService + opsSvc *ops.Service appID string appSecret string verificationToken string encryptKey string } -func NewBot(db *gorm.DB, finance *service.FinanceService, appID, appSecret, verificationToken, encryptKey string) *Bot { +func NewBot(db *gorm.DB, finance *service.FinanceService, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot { return &Bot{ db: db, finance: finance, + opsSvc: opsSvc, appID: appID, appSecret: appSecret, verificationToken: verificationToken, @@ -93,9 +96,14 @@ func (b *Bot) handleWebhook(c *gin.Context) { func (b *Bot) handleText(text string) string { trim := strings.TrimSpace(text) + if b.opsSvc != nil { + if handled, out := b.opsSvc.Handle(DefaultUserID, trim); handled { + return out + } + } switch trim { case "帮助", "help", "/help", "菜单", "功能", "/start": - return "🦞 虾记记账\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计" + return "🛠️ Ops-Assistant\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计" case "查看", "记录", "列表", "最近": items, err := b.finance.GetTransactions(DefaultUserID, 10) if err != nil { diff --git a/internal/module/cf/commands.go b/internal/module/cf/commands.go index fbab11a..b6d613f 100644 --- a/internal/module/cf/commands.go +++ b/internal/module/cf/commands.go @@ -33,6 +33,7 @@ func commandSpecs() []coremodule.CommandSpec { }, DryRunMsg: "🧪 dry-run: 将执行 /cf zones(未实际执行)", SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf zones 已执行,job=%d", jobID) }, + ResultFormatter: func(out string) (string, bool) { return FormatZones(out) }, }, ErrPrefix: "/cf zones 执行失败: ", }, @@ -44,7 +45,19 @@ func commandSpecs() []coremodule.CommandSpec { if len(parts) < 4 { return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns list ")) } - return map[string]string{"zone_id": parts[3]}, nil + zoneID := parts[3] + if !isHex32(zoneID) { + acct, tok := cfSettings() + if acct == "" || tok == "" { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "cf 凭据未配置,无法解析域名;请直接传 zone_id")) + } + resolved, _, err := ResolveZoneIDIfDomain(zoneID, acct, tok) + if err != nil { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, err.Error())) + } + zoneID = resolved + } + return map[string]string{"zone_id": zoneID}, nil }, Gate: coremodule.Gate{ NeedFlag: "enable_module_cf", @@ -52,6 +65,7 @@ func commandSpecs() []coremodule.CommandSpec { }, DryRunMsg: "🧪 dry-run: 将执行 /cf dns list (未实际执行)", SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行,job=%d", jobID) }, + ResultFormatter: func(out string) (string, bool) { return FormatDNSList(out) }, }, ErrPrefix: "/cf dns list 执行失败: ", ErrHint: "/cf dns list ", @@ -247,6 +261,7 @@ func commandSpecs() []coremodule.CommandSpec { }, DryRunMsg: "🧪 dry-run: 将执行 /cf workers list(未实际执行)", SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf workers list 已执行,job=%d", jobID) }, + ResultFormatter: func(out string) (string, bool) { return FormatWorkers(out) }, }, ErrPrefix: "/cf workers list 执行失败: ", }, diff --git a/internal/module/cf/format.go b/internal/module/cf/format.go new file mode 100644 index 0000000..1848932 --- /dev/null +++ b/internal/module/cf/format.go @@ -0,0 +1,224 @@ +package cf + +import ( + "encoding/json" + "fmt" + "os/exec" + "sort" + "strconv" + "strings" + + "gorm.io/gorm" + "ops-assistant/internal/core/runbook" +) + +// ModuleRef 由 module.go 注入 db 句柄,避免循环依赖 +var ModuleRef struct { + DB *gorm.DB +} + +// cfSettings 从 app_settings 读取 CF 凭据(与 executor 相同存储约定) +func cfSettings() (accountID, apiToken string) { + if ModuleRef.DB == nil { + return "", "" + } + type setting struct { + Key string + Value string + } + var rows []setting + if err := ModuleRef.DB.Table("app_settings").Where("key IN ?", []string{"cf_account_id", "cf_api_token"}).Find(&rows).Error; err != nil { + return "", "" + } + for _, r := range rows { + switch r.Key { + case "cf_account_id": + accountID = r.Value + case "cf_api_token": + apiToken = r.Value + } + } + return accountID, apiToken +} + +// ResolveZoneIDIfDomain 判断 zoneIDOrName 是否为域名(非 32 位十六进制), +// 若是则调用 zones runbook 的同款 API 逻辑解析为 zone_id。 +// 返回 (解析后的 zone_id, 是否发生了替换, error)。 +func ResolveZoneIDIfDomain(zoneIDOrName, accountID, apiToken string) (string, bool, error) { + s := strings.TrimSpace(zoneIDOrName) + if s == "" || isHex32(s) { + return s, false, nil + } + zones, err := fetchZones(accountID, apiToken) + if err != nil { + return "", false, err + } + for _, z := range zones { + if strings.EqualFold(z.Name, s) { + return z.ID, true, nil + } + } + return "", false, fmt.Errorf("找不到域名 %s 对应的 zone(账号下无此域名或无权限)", s) +} + +func isHex32(s string) bool { + if len(s) != 32 { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +type cfZone struct { + ID string + Name string +} + +func fetchZones(accountID, apiToken string) ([]cfZone, error) { + // 与 runbooks/cf_zones.yaml 相同的 API 调用方式 + url := "https://api.cloudflare.com/client/v4/zones?account.id=" + accountID + "&per_page=200" + body, err := cfHTTPGet(url, apiToken) + if err != nil { + return nil, err + } + var data struct { + Success bool `json:"success"` + Result []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"result"` + Errors []json.RawMessage `json:"errors"` + } + if err := json.Unmarshal([]byte(body), &data); err != nil { + return nil, fmt.Errorf("zones 响应解析失败: %v", err) + } + if !data.Success { + return nil, fmt.Errorf("zones API 返回 success=false") + } + out := make([]cfZone, 0, len(data.Result)) + for _, z := range data.Result { + out = append(out, cfZone{ID: z.ID, Name: z.Name}) + } + return out, nil +} + +func cfHTTPGet(url, apiToken string) (string, error) { + // 通过 SSH 在 hwsg 上用 python3+requests 发请求(与 runbook 网络环境一致) + script := fmt.Sprintf( + "python3 -c \"import os,requests; token=os.environ['CF_TOKEN']; resp=requests.get(os.environ['CF_URL'], headers={'Authorization':'Bearer '+token}, timeout=15); print(resp.text)\"", + ) + out, err := sshExec(script, map[string]string{"CF_TOKEN": apiToken, "CF_URL": url}) + if err != nil { + return "", err + } + return out, nil +} + +func sshExec(script string, env map[string]string) (string, error) { + if ModuleRef.DB == nil { + return "", fmt.Errorf("db 未初始化,无法解析目标主机") + } + // 解析 hwsg 目标(与 runbook targets 相同约定) + resolved := runbook.ResolveTargetForModule(ModuleRef.DB, "hwsg") + if !resolved.Found { + return "", fmt.Errorf("未找到目标主机 hwsg") + } + envStr := "" + for k, v := range env { + envStr += fmt.Sprintf("%s=%q ", k, v) + } + args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, envStr + script} + cmd := exec.Command("ssh", args...) + out, err := cmd.Output() + return string(out), err +} + +// 以下为格式化函数(ResultFormatter 用) + +type dnsRecord struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Content string `json:"content"` + Proxied bool `json:"proxied"` + TTL int `json:"ttl"` + Comment string `json:"comment"` +} + +// FormatDNSList 把 cf_dns_list 的 stdout(CF API 原始 JSON)格式化为可读消息 +func FormatDNSList(out string) (string, bool) { + var data struct { + Success bool `json:"success"` + Result []dnsRecord + } + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil { + return "", false + } + if !data.Success { + return "", false + } + recs := data.Result + // 排序:类型 -> 名称 + sort.Slice(recs, func(i, j int) bool { + if recs[i].Type != recs[j].Type { + return recs[i].Type < recs[j].Type + } + return recs[i].Name < recs[j].Name + }) + var sb strings.Builder + sb.WriteString(fmt.Sprintf("📋 DNS 记录(共 %d 条):\n", len(recs))) + for _, r := range recs { + proxyMark := "" + if r.Proxied { + proxyMark = " 🟠" + } + sb.WriteString(fmt.Sprintf("• %s %s → %s%s\n", r.Type, r.Name, r.Content, proxyMark)) + } + return strings.TrimRight(sb.String(), "\n"), true +} + +// FormatZones 把 cf_zones 的 stdout 格式化为可读消息 +func FormatZones(out string) (string, bool) { + var data struct { + Success bool `json:"success"` + Zones []struct { + Name string `json:"name"` + ID string `json:"id"` + } `json:"zones"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil { + return "", false + } + if !data.Success { + return "", false + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🌐 Zones(共 %d 个):\n", len(data.Zones))) + for _, z := range data.Zones { + sb.WriteString(fmt.Sprintf("• %s\n %s\n", z.Name, z.ID)) + } + return strings.TrimRight(sb.String(), "\n"), true +} + +// FormatWorkers 把 cf_workers_list 的 stdout 格式化为可读消息 +func FormatWorkers(out string) (string, bool) { + var data struct { + Workers []string `json:"workers"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil { + return "", false + } + if data.Workers == nil { + return "", false + } + var sb strings.Builder + sb.WriteString(fmt.Sprintf("⚙️ Workers(共 %d 个):\n", len(data.Workers))) + for _, w := range data.Workers { + sb.WriteString("• " + w + "\n") + } + return strings.TrimRight(sb.String(), "\n"), true +} diff --git a/internal/module/cf/module.go b/internal/module/cf/module.go index 332af4b..0fc1171 100644 --- a/internal/module/cf/module.go +++ b/internal/module/cf/module.go @@ -17,6 +17,7 @@ type Module struct { } func New(db *gorm.DB, exec *runbook.Executor) *Module { + ModuleRef.DB = db return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)} } @@ -34,6 +35,10 @@ func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error if out == "dry-run" { return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil } + // 查询类命令:结果已由 ResultFormatter 格式化;写操作回退到回执消息 + if out != "" && out != "dry-run" && len(out) > 2 { + return ecode.Tag("OK", out), nil + } return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil } return ecode.Tag(ecode.ErrStepFailed, "CF 模块已接入,当前支持:/cf status, /cf help"), nil diff --git a/internal/module/cfdav/module.go b/internal/module/cfdav/module.go new file mode 100644 index 0000000..e3915b2 --- /dev/null +++ b/internal/module/cfdav/module.go @@ -0,0 +1,253 @@ +package cfdav + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "ops-assistant/internal/core/command" + "ops-assistant/internal/core/ecode" + "ops-assistant/models" + + "gorm.io/gorm" +) + +type Module struct { + db *gorm.DB +} + +type settings struct { + APIBase string + AdminUser string + AdminPass string +} + +type userItem struct { + ID string `json:"id"` + Email string `json:"email"` + IsAdmin int `json:"is_admin"` + CreatedAt string `json:"created_at"` +} + +type listResp struct { + Success bool `json:"success"` + Data []userItem `json:"data"` + Error string `json:"error"` +} + +type simpleResp struct { + Success bool `json:"success"` + Error string `json:"error"` +} + +func New(db *gorm.DB) *Module { + return &Module{db: db} +} + +func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) { + text := strings.TrimSpace(cmd.Raw) + if text == "/cfdav" || strings.HasPrefix(text, "/cfdav help") { + return "cfdav 模块\n- /cfdav users\n- /cfdav add [admin]\n- /cfdav del ", nil + } + + parts := strings.Fields(text) + if len(parts) == 0 { + return ecode.Tag(ecode.ErrStepFailed, "空命令"), nil + } + // 支持别名:/cfdav list + if len(parts) >= 2 && (parts[1] == "users" || parts[1] == "list") { + return m.listUsers() + } + if len(parts) >= 2 && (parts[1] == "add" || (len(parts) >= 3 && parts[1] == "user" && parts[2] == "add")) { + return m.addUser(parts) + } + if len(parts) >= 2 && (parts[1] == "del" || parts[1] == "delete" || (len(parts) >= 3 && parts[1] == "user" && (parts[2] == "del" || parts[2] == "delete"))) { + return m.deleteUser(parts) + } + return ecode.Tag(ecode.ErrStepFailed, "cfdav 模块已接入,当前支持:/cfdav users, /cfdav add, /cfdav del"), nil +} + +func (m *Module) listUsers() (string, error) { + cfg, err := m.loadSettings() + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users" + resp, status, err := m.doRequest(http.MethodGet, url, nil, cfg) + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + if status >= 400 { + return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil + } + var out listResp + if err := json.Unmarshal([]byte(resp), &out); err != nil { + return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil + } + if !out.Success { + return ecode.Tag(ecode.ErrStepFailed, out.Error), nil + } + if len(out.Data) == 0 { + return "📭 cfdav 用户列表为空", nil + } + limit := len(out.Data) + if limit > 50 { + limit = 50 + } + lines := make([]string, 0, limit+2) + lines = append(lines, fmt.Sprintf("✅ cfdav 用户列表(%d)", len(out.Data))) + for i := 0; i < limit; i++ { + u := out.Data[i] + admin := "user" + if u.IsAdmin == 1 { + admin = "admin" + } + lines = append(lines, fmt.Sprintf("- %s | %s | %s", u.ID, u.Email, admin)) + } + if len(out.Data) > limit { + lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(out.Data), limit)) + } + return strings.Join(lines, "\n"), nil +} + +func (m *Module) addUser(parts []string) (string, error) { + cfg, err := m.loadSettings() + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + // 支持: /cfdav add [admin] + // 或: /cfdav user add [admin] + idx := 2 + if len(parts) >= 3 && parts[1] == "user" { + idx = 3 + } + if len(parts) < idx+2 { + return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav add [admin]"), nil + } + email := strings.TrimSpace(parts[idx]) + pass := strings.TrimSpace(parts[idx+1]) + if email == "" || pass == "" { + return ecode.Tag(ecode.ErrStepFailed, "email/password 不能为空"), nil + } + isAdmin := false + if len(parts) >= idx+3 { + flag := strings.ToLower(strings.TrimSpace(parts[idx+2])) + isAdmin = flag == "admin" || flag == "1" || flag == "true" || flag == "yes" + } + payload := map[string]any{"email": email, "password": pass, "isAdmin": isAdmin} + body, _ := json.Marshal(payload) + url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users" + resp, status, err := m.doRequest(http.MethodPost, url, body, cfg) + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + if status >= 400 { + return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil + } + var out simpleResp + if err := json.Unmarshal([]byte(resp), &out); err != nil { + return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil + } + if !out.Success { + return ecode.Tag(ecode.ErrStepFailed, out.Error), nil + } + return fmt.Sprintf("✅ cfdav 用户已创建:%s", email), nil +} + +func (m *Module) deleteUser(parts []string) (string, error) { + cfg, err := m.loadSettings() + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + // 支持: /cfdav del 或 /cfdav user del + idx := 2 + if len(parts) >= 3 && parts[1] == "user" { + idx = 3 + } + if len(parts) < idx+1 { + return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav del "), nil + } + id := strings.TrimSpace(parts[idx]) + if id == "" { + return ecode.Tag(ecode.ErrStepFailed, "user_id 不能为空"), nil + } + url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users/" + id + resp, status, err := m.doRequest(http.MethodDelete, url, nil, cfg) + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + if status >= 400 { + return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil + } + var out simpleResp + if err := json.Unmarshal([]byte(resp), &out); err != nil { + return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil + } + if !out.Success { + return ecode.Tag(ecode.ErrStepFailed, out.Error), nil + } + return fmt.Sprintf("✅ cfdav 用户已删除:%s", id), nil +} + +func (m *Module) loadSettings() (*settings, error) { + if m.db == nil { + return nil, errors.New("db 未初始化") + } + keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"} + cfg := &settings{} + for _, k := range keys { + var sset models.AppSetting + val := "" + if err := m.db.Where("key = ?", k).First(&sset).Error; err == nil { + val = sset.Value + } + switch k { + case "cfdav_api_base": + cfg.APIBase = strings.TrimSpace(val) + case "cfdav_admin_user": + cfg.AdminUser = strings.TrimSpace(val) + case "cfdav_admin_pass": + cfg.AdminPass = val + } + } + if cfg.APIBase == "" { + return nil, errors.New("cfdav api_base 未配置") + } + if cfg.AdminUser == "" || cfg.AdminPass == "" { + return nil, errors.New("cfdav admin 账号未配置") + } + return cfg, nil +} + +func (m *Module) doRequest(method, url string, body []byte, cfg *settings) (string, int, error) { + var reader io.Reader + if body != nil { + reader = strings.NewReader(string(body)) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + return "", 0, err + } + auth := cfg.AdminUser + ":" + cfg.AdminPass + b64 := base64.StdEncoding.EncodeToString([]byte(auth)) + req.Header.Set("Authorization", "Basic "+b64) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", resp.StatusCode, err + } + return string(data), resp.StatusCode, nil +} diff --git a/internal/module/cpa/commands.go b/internal/module/cpa/commands.go new file mode 100644 index 0000000..d157508 --- /dev/null +++ b/internal/module/cpa/commands.go @@ -0,0 +1,62 @@ +package cpa + +import ( + "fmt" + "strings" + + coremodule "ops-assistant/internal/core/module" + "ops-assistant/internal/core/runbook" +) + +func commandSpecs() []coremodule.CommandSpec { + return []coremodule.CommandSpec{ + { + Prefixes: []string{"/cpa status"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cpa_status", + Gate: coremodule.Gate{AllowDryRun: true}, + DryRunMsg: "🧪 dry-run: 将执行 /cpa status(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa status 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cpa status 执行失败: ", + }, + { + Prefixes: []string{"/cpa usage backup"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cpa_usage_backup", + Gate: coremodule.Gate{AllowDryRun: true}, + DryRunMsg: "🧪 dry-run: 将执行 /cpa usage backup(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage backup 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cpa usage backup 执行失败: ", + }, + { + Prefixes: []string{"/cpa usage restore "}, + ErrHint: "--confirm YES_RESTORE", + Template: coremodule.CommandTemplate{ + RunbookName: "cpa_usage_restore", + Gate: coremodule.Gate{NeedFlag: "allow_ops_restore", RequireConfirm: true, ExpectedToken: "YES_RESTORE", AllowDryRun: true}, + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 4 { + return nil, fmt.Errorf("❌ 用法:/cpa usage restore ") + } + backupID := strings.TrimSpace(parts[3]) + if backupID == "" { + return nil, fmt.Errorf("❌ backup_id 不能为空") + } + return map[string]string{"backup_id": backupID}, nil + }, + MetaFn: func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta { + meta := runbook.NewMeta() + meta.Target = "hwsg" + meta.RiskLevel = "high" + meta.ConfirmHash = hashConfirmToken(confirmToken) + return meta + }, + DryRunMsg: "🧪 dry-run: 将执行 restore(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage restore 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cpa usage restore 执行失败: ", + }, + } +} diff --git a/internal/module/cpa/crypto.go b/internal/module/cpa/crypto.go new file mode 100644 index 0000000..f082ed2 --- /dev/null +++ b/internal/module/cpa/crypto.go @@ -0,0 +1,16 @@ +package cpa + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +func hashConfirmToken(token string) string { + t := strings.TrimSpace(token) + if t == "" { + return "" + } + sum := sha256.Sum256([]byte(t)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/module/cpa/guards.go b/internal/module/cpa/guards.go new file mode 100644 index 0000000..0364d82 --- /dev/null +++ b/internal/module/cpa/guards.go @@ -0,0 +1,13 @@ +package cpa + +import ( + "ops-assistant/internal/core/ecode" +) + +func formatErr(code, msg string) string { + return ecode.Tag(code, msg) +} + +func formatOK(msg string) string { + return ecode.Tag("OK", msg) +} diff --git a/internal/module/cpa/module.go b/internal/module/cpa/module.go new file mode 100644 index 0000000..a6b66ed --- /dev/null +++ b/internal/module/cpa/module.go @@ -0,0 +1,40 @@ +package cpa + +import ( + "strings" + + "ops-assistant/internal/core/command" + "ops-assistant/internal/core/ecode" + coremodule "ops-assistant/internal/core/module" + "ops-assistant/internal/core/runbook" + "gorm.io/gorm" +) + +type Module struct { + db *gorm.DB + exec *runbook.Executor + runner *coremodule.Runner +} + +func New(db *gorm.DB, exec *runbook.Executor) *Module { + return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)} +} + +func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) { + text := strings.TrimSpace(cmd.Raw) + if text == "/cpa" || strings.HasPrefix(text, "/cpa help") { + return "CPA 模块\n- /cpa status\n- /cpa usage backup\n- /cpa usage restore [--confirm YES_RESTORE] [--dry-run]", nil + } + specs := commandSpecs() + if sp, ok := coremodule.MatchCommand(text, specs); ok { + jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template) + if err != nil { + return formatErr(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil + } + if out == "dry-run" { + return formatOK(coremodule.FormatDryRunMessage(sp.Template)), nil + } + return formatOK(coremodule.FormatSuccessMessage(sp.Template, jobID)), nil + } + return "❓ 暂不支持该 CPA 命令。当前支持:/cpa status, /cpa usage backup, /cpa usage restore ", nil +} diff --git a/internal/module/mail/commands.go b/internal/module/mail/commands.go new file mode 100644 index 0000000..f0f0d53 --- /dev/null +++ b/internal/module/mail/commands.go @@ -0,0 +1,25 @@ +package mail + +import ( + "fmt" + + coremodule "ops-assistant/internal/core/module" +) + +func commandSpecs() []coremodule.CommandSpec { + return []coremodule.CommandSpec{ + { + Prefixes: []string{"/mail status"}, + Template: coremodule.CommandTemplate{ + RunbookName: "mail_status", + Gate: coremodule.Gate{ + NeedFlag: "enable_module_mail", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /mail status(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /mail status 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/mail status 执行失败: ", + }, + } +} diff --git a/internal/module/mail/module.go b/internal/module/mail/module.go new file mode 100644 index 0000000..cc28aa1 --- /dev/null +++ b/internal/module/mail/module.go @@ -0,0 +1,40 @@ +package mail + +import ( + "strings" + + "ops-assistant/internal/core/command" + "ops-assistant/internal/core/ecode" + coremodule "ops-assistant/internal/core/module" + "ops-assistant/internal/core/runbook" + "gorm.io/gorm" +) + +type Module struct { + db *gorm.DB + exec *runbook.Executor + runner *coremodule.Runner +} + +func New(db *gorm.DB, exec *runbook.Executor) *Module { + return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)} +} + +func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) { + text := strings.TrimSpace(cmd.Raw) + if text == "/mail" || strings.HasPrefix(text, "/mail help") { + return "Mail 模块\n- /mail status [--dry-run]", nil + } + specs := commandSpecs() + if sp, ok := coremodule.MatchCommand(text, specs); ok { + jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template) + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil + } + if out == "dry-run" { + return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil + } + return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil + } + return ecode.Tag(ecode.ErrStepFailed, "Mail 模块已接入,当前支持:/mail status, /mail help"), nil +} diff --git a/internal/module/sub/module.go b/internal/module/sub/module.go new file mode 100644 index 0000000..b754dee --- /dev/null +++ b/internal/module/sub/module.go @@ -0,0 +1,217 @@ +package sub + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "ops-assistant/internal/core/command" + "ops-assistant/internal/core/ecode" + "ops-assistant/models" + + "gorm.io/gorm" +) + +type Module struct { + db *gorm.DB +} + +type settings struct { + APIBase string + Token string +} + +type subItem struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Account string `json:"account"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + DueDate string `json:"due_date"` + Cycle string `json:"cycle"` + CycleUnit string `json:"cycle_unit"` + CycleValue int `json:"cycle_value"` + Status string `json:"status"` + UserID string `json:"user_id"` + Channel string `json:"channel"` + Remind []int `json:"remind_days"` +} + +type listResp struct { + Success bool `json:"success"` + Data []subItem `json:"data"` + Error string `json:"error"` +} + +type simpleResp struct { + Success bool `json:"success"` + Error string `json:"error"` +} + +func New(db *gorm.DB) *Module { + return &Module{db: db} +} + +func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) { + text := strings.TrimSpace(cmd.Raw) + if text == "/sub" || strings.HasPrefix(text, "/sub help") { + return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del \n- /sub cancel(或 /cancel)取消新增流程", nil + } + parts := strings.Fields(text) + if len(parts) < 2 { + return "❌ 用法:/sub add | /sub list | /sub del ", nil + } + switch parts[1] { + case "add": + return "🧩 进入订阅新增流程:名称→分类→账号(可选)→金额→币种→到期日→周期单位→周期数量", nil + case "list": + return m.list(userID) + case "report": + return m.report(userID, parts[2:]) + case "del", "delete": + if len(parts) < 3 { + return "❌ 用法:/sub del ", nil + } + return m.del(userID, strings.TrimSpace(parts[2])) + default: + return "❌ 用法:/sub add | /sub list | /sub del ", nil + } +} + +func formatCycleText(unit string, value int, legacy string) string { + u := strings.TrimSpace(unit) + if u == "" { + if strings.TrimSpace(legacy) != "" { + return legacy + } + return "-" + } + if value <= 0 { + value = 1 + } + return fmt.Sprintf("%s x%d", u, value) +} + +func (m *Module) list(userID int64) (string, error) { + cfg, err := m.loadSettings() + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions?user_id=1&channel=qq" + body, status, err := m.doRequest(http.MethodGet, url, nil, cfg) + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + if status >= 400 { + return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, body)), nil + } + var out listResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil + } + if !out.Success { + return ecode.Tag(ecode.ErrStepFailed, out.Error), nil + } + if len(out.Data) == 0 { + return "📭 订阅列表为空", nil + } + lines := make([]string, 0, len(out.Data)+2) + lines = append(lines, fmt.Sprintf("✅ 订阅列表(%d)", len(out.Data))) + limit := len(out.Data) + if limit > 50 { + limit = 50 + } + for i := 0; i < limit; i++ { + it := out.Data[i] + cycleText := formatCycleText(it.CycleUnit, it.CycleValue, it.Cycle) + line := fmt.Sprintf("- ID=%s | %s | 分类:%s | 到期:%s | 周期:%s | 金额:%.2f %s", it.ID, it.Name, it.Category, it.DueDate, cycleText, it.Amount, it.Currency) + if strings.TrimSpace(it.Account) != "" { + line += fmt.Sprintf(" | 账号:%s", it.Account) + } + lines = append(lines, line) + } + if len(out.Data) > limit { + lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(out.Data), limit)) + } + return strings.Join(lines, "\n"), nil +} + +func (m *Module) del(userID int64, id string) (string, error) { + if strings.TrimSpace(id) == "" { + return "❌ id 不能为空", nil + } + cfg, err := m.loadSettings() + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions/" + id + "?user_id=1&channel=qq" + body, status, err := m.doRequest(http.MethodDelete, url, nil, cfg) + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil + } + if status >= 400 { + return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, body)), nil + } + var out simpleResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil + } + if !out.Success { + return ecode.Tag(ecode.ErrStepFailed, out.Error), nil + } + return fmt.Sprintf("✅ 订阅已删除:%s", id), nil +} + +func (m *Module) loadSettings() (*settings, error) { + if m.db == nil { + return nil, errors.New("db 未初始化") + } + keys := []string{"sub_api_base", "sub_api_token"} + cfg := &settings{} + for _, k := range keys { + var s models.AppSetting + val := "" + if err := m.db.Where("key = ?", k).First(&s).Error; err == nil { + val = strings.TrimSpace(s.Value) + } + switch k { + case "sub_api_base": + cfg.APIBase = strings.TrimSpace(val) + case "sub_api_token": + cfg.Token = val + } + } + if cfg.APIBase == "" { + return nil, errors.New("sub_api_base 未配置") + } + if cfg.Token == "" { + return nil, errors.New("sub_api_token 未配置") + } + return cfg, nil +} + +func (m *Module) doRequest(method, url string, body []byte, cfg *settings) (string, int, error) { + req, err := http.NewRequest(method, url, strings.NewReader(string(body))) + if err != nil { + return "", 0, err + } + if cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+cfg.Token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return string(data), resp.StatusCode, nil +} diff --git a/internal/module/sub/report.go b/internal/module/sub/report.go new file mode 100644 index 0000000..167d57d --- /dev/null +++ b/internal/module/sub/report.go @@ -0,0 +1,127 @@ +package sub + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +type reportSummaryResp struct { + Success bool `json:"success"` + Data struct { + Total int `json:"total"` + Currency []struct { + Currency string `json:"currency"` + Cnt int `json:"cnt"` + Total float64 `json:"total"` + } `json:"currency"` + Due struct { + Overdue int `json:"overdue"` + Due7 int `json:"due7"` + Due30 int `json:"due30"` + } `json:"due"` + } `json:"data"` + Error string `json:"error"` +} + +type reportListResp struct { + Success bool `json:"success"` + Data []map[string]any `json:"data"` + Error string `json:"error"` +} + +func (m *Module) report(userID int64, args []string) (string, error) { + cfg, err := m.loadSettings() + if err != nil { + return "❌ " + err.Error(), nil + } + typeName := "summary" + days := "" + if len(args) >= 1 { + typeName = strings.ToLower(strings.TrimSpace(args[0])) + } + if len(args) >= 2 { + days = strings.TrimSpace(args[1]) + } + + base := strings.TrimRight(cfg.APIBase, "/") + "/api/report?user_id=1&channel=qq&type=" + typeName + if days != "" { + base += "&days=" + days + } + body, status, err := m.doRequest(http.MethodGet, base, nil, cfg) + if err != nil { + return "❌ 报表请求失败: " + err.Error(), nil + } + if status >= 400 { + return fmt.Sprintf("❌ 报表失败: http %d %s", status, body), nil + } + + switch typeName { + case "summary": + var out reportSummaryResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + return "❌ 报表解析失败", nil + } + if !out.Success { + return "❌ " + out.Error, nil + } + lines := []string{fmt.Sprintf("📊 订阅总览:%d 项", out.Data.Total)} + if len(out.Data.Currency) > 0 { + lines = append(lines, "💰 按币种汇总:") + for _, c := range out.Data.Currency { + lines = append(lines, fmt.Sprintf("- %s: %.2f(%d项)", c.Currency, c.Total, c.Cnt)) + } + } + lines = append(lines, fmt.Sprintf("⏰ 到期:7天内=%d,30天内=%d,已逾期=%d", out.Data.Due.Due7, out.Data.Due.Due30, out.Data.Due.Overdue)) + return strings.Join(lines, "\n"), nil + case "category", "cycle", "due": + var out reportListResp + if err := json.Unmarshal([]byte(body), &out); err != nil { + return "❌ 报表解析失败", nil + } + if !out.Success { + return "❌ " + out.Error, nil + } + if len(out.Data) == 0 { + return "📭 无数据", nil + } + lines := []string{fmt.Sprintf("📊 报表(%s):%d 条", typeName, len(out.Data))} + max := len(out.Data) + if max > 30 { + max = 30 + } + for i := 0; i < max; i++ { + r := out.Data[i] + switch typeName { + case "category": + lines = append(lines, fmt.Sprintf("- %v | %v | 数量:%v | 金额:%.2f", r["category"], r["currency"], r["cnt"], toF64(r["total"]))) + case "cycle": + lines = append(lines, fmt.Sprintf("- %v x%v | 数量:%v", r["cycle_unit"], r["cycle_value"], r["cnt"])) + case "due": + lines = append(lines, fmt.Sprintf("- %v | %v | 分类:%v | 到期:%v | 周期:%v x%v | %.2f %v", r["id"], r["name"], r["category"], r["due_date"], r["cycle_unit"], r["cycle_value"], toF64(r["amount"]), r["currency"])) + } + } + if len(out.Data) > max { + lines = append(lines, fmt.Sprintf("... 共%d条,仅显示前%d条", len(out.Data), max)) + } + return strings.Join(lines, "\n"), nil + default: + return "❌ 用法:/sub report | /sub report category | /sub report cycle | /sub report due <7|30|overdue>", nil + } +} + +func toF64(v any) float64 { + switch t := v.(type) { + case float64: + return t + case string: + f, _ := strconv.ParseFloat(t, 64) + return f + case int: + return float64(t) + default: + return 0 + } +} diff --git a/internal/qq/ai_loader.go b/internal/qq/ai_loader.go new file mode 100644 index 0000000..25d17cb --- /dev/null +++ b/internal/qq/ai_loader.go @@ -0,0 +1,11 @@ +package qq + +import ( + "ops-assistant/internal/core/ai" + + "gorm.io/gorm" +) + +func buildAIClient(db *gorm.DB) *ai.Client { + return ai.LoadClient(db) +} diff --git a/internal/qq/qq.go b/internal/qq/qq.go index d4d1492..6f019e9 100644 --- a/internal/qq/qq.go +++ b/internal/qq/qq.go @@ -4,11 +4,17 @@ import ( "context" "fmt" "log" + "path" + "regexp" + "strconv" "strings" "time" - "xiaji-go/internal/service" - "xiaji-go/models" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/service" + + "ops-assistant/internal/core/ai" + "ops-assistant/models" "github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo/dto" @@ -16,6 +22,7 @@ import ( "github.com/tencent-connect/botgo/event" "github.com/tencent-connect/botgo/openapi" "github.com/tencent-connect/botgo/token" + "github.com/tidwall/gjson" "gorm.io/gorm" ) @@ -23,20 +30,26 @@ import ( const DefaultUserID int64 = 1 type QQBot struct { - api openapi.OpenAPI - finance *service.FinanceService - credentials *token.QQBotCredentials - db *gorm.DB + api openapi.OpenAPI + finance *service.FinanceService + credentials *token.QQBotCredentials + db *gorm.DB + opsSvc *ops.Service + aiClient *ai.Client + aiAutoReload time.Time } -func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService) *QQBot { +func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, opsSvc *ops.Service) *QQBot { return &QQBot{ db: db, finance: finance, + opsSvc: opsSvc, credentials: &token.QQBotCredentials{ AppID: appID, AppSecret: secret, }, + aiClient: buildAIClient(db), + aiAutoReload: time.Now(), } } @@ -97,87 +110,315 @@ func (b *QQBot) processAndReply(userID string, content string) string { return "" } - today := time.Now().Format("2006-01-02") - - switch { - case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"): - return "🦞 虾记记账\n\n" + - "直接发送消费描述即可记账:\n" + - "• 午饭 25元\n" + - "• 打车 ¥30\n" + - "• 买咖啡15块\n\n" + - "📋 命令列表:\n" + - "• 记录/查看 — 最近10条\n" + - "• 今日/今天 — 今日汇总\n" + - "• 统计/报表 — 本月分类统计\n" + - "• 帮助 — 本帮助信息" - - case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"): - items, err := b.finance.GetTransactions(DefaultUserID, 10) - if err != nil { - return "❌ 查询失败" - } - if len(items) == 0 { - return "📭 暂无记录" - } - var sb strings.Builder - sb.WriteString("📋 最近记录:\n\n") - for _, item := range items { - sb.WriteString(fmt.Sprintf("• [%s] %s:%.2f元\n", item.Date, item.Category, item.AmountYuan())) - } - return sb.String() - - case isCommand(text, "今日", "今天", "today"): - items, err := b.finance.GetTransactionsByDate(DefaultUserID, today) - if err != nil { - return "❌ 查询失败" - } - if len(items) == 0 { - return fmt.Sprintf("📭 %s 暂无消费记录", today) - } - var sb strings.Builder - var total int64 - sb.WriteString(fmt.Sprintf("📊 今日(%s)消费:\n\n", today)) - for _, item := range items { - sb.WriteString(fmt.Sprintf("• %s:%.2f元\n", item.Category, item.AmountYuan())) - total += item.Amount - } - sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", len(items), float64(total)/100.0)) - return sb.String() - - case isCommand(text, "统计", "报表", "图表", "chart", "/chart"): - now := time.Now() - dateFrom := now.Format("2006-01") + "-01" - dateTo := now.Format("2006-01-02") - stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo) - if err != nil || len(stats) == 0 { - return fmt.Sprintf("📭 %d年%d月暂无消费数据", now.Year(), now.Month()) - } - var sb strings.Builder - var grandTotal int64 - var grandCount int - sb.WriteString(fmt.Sprintf("📊 %d年%d月消费统计:\n\n", now.Year(), now.Month())) - for _, s := range stats { - yuan := float64(s.Total) / 100.0 - sb.WriteString(fmt.Sprintf("• %s:%.2f元(%d笔)\n", s.Category, yuan, s.Count)) - grandTotal += s.Total - grandCount += s.Count - } - sb.WriteString(fmt.Sprintf("\n💰 共 %d 笔,合计 %.2f 元", grandCount, float64(grandTotal)/100.0)) - return sb.String() + // sub 引导式新增流程(优先级高于模块命令) + if reply, handled := b.handleSubFlow(userID, text); handled { + return reply } - amount, category, err := b.finance.AddTransaction(DefaultUserID, text) + // 先交给 opsSvc 处理命令 + if b.opsSvc != nil { + if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled { + if strings.HasPrefix(text, "/cpa ") || text == "/cpa" || strings.HasPrefix(text, "/cf ") || strings.HasPrefix(text, "/mail ") || strings.HasPrefix(text, "/cfdav ") || text == "/cfdav" { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, text) + } + } + return out + } + } + + // 规则兜底:常见自然语映射到标准命令 + if b.opsSvc != nil { + norm := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(text), " ", ""), " ", "") + if (strings.Contains(norm, "域名") || strings.Contains(norm, "站点")) && !strings.Contains(norm, "解析") && !strings.Contains(strings.ToLower(norm), "dns") { + cmd := "/cf zones" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + } + switch norm { + case "cpa状态", "CPA状态", "cpaStatus", "cpastatus": + cmd := "/cpa status" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + case "功能", "菜单", "帮助", "help", "Help", "HELP", "你能做什么", "你会什么": + cmd := "/help" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + return out + } + case "cf状态", "cf配置", "cf配置状态", "cloudflare状态", "cloudflare配置": + cmd := "/cf status" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + case "cf域名", "cf账号域名", "cfzones", "cf zones", "cloudflare域名", "cloudflare站点", "站点列表", "域名列表", "我的域名", "域名清单": + cmd := "/cf zones" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + case "cf解析", "cf记录", "解析记录", "dns记录", "dns列表", "列解析", "列记录": + return "❌ 缺少 zone_id,请用:/cf dns list " + case "cfworkers", "cf workers", "workers列表", "workers list", "列workers": + cmd := "/cf workers list" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + } + } + + // 非命令:尝试 AI 翻译 -> 标准命令 + if time.Since(b.aiAutoReload) > 3*time.Second { + b.aiClient = buildAIClient(b.db) + b.aiAutoReload = time.Now() + } + if b.aiClient != nil { + if cmd, err := b.aiClient.Suggest(text); err == nil { + cmd = strings.TrimSpace(cmd) + if cmd != "" && cmd != "FAIL" && strings.HasPrefix(cmd, "/") { + // 仅翻译成命令,交给模块处理(不回译文) + if b.opsSvc != nil { + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + if strings.HasPrefix(cmd, "/cpa ") || cmd == "/cpa" || strings.HasPrefix(cmd, "/cf ") || strings.HasPrefix(cmd, "/mail ") { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + } + return out + } + } + return "❌ 无法识别,请使用标准命令" + } + return "❌ 无法识别,请使用标准命令" + } else { + if strings.Contains(err.Error(), "rate limited") { + return "⚠️ AI 服务繁忙,请稍后再试或使用标准命令" + } + return "❌ 无法识别,请使用标准命令" + } + } + // 仍然无法处理 + return "❌ 无法识别,请使用标准命令" +} + +func parseJobID(out string) uint { + re := regexp.MustCompile(`job=(\d+)`) + m := re.FindStringSubmatch(out) + if len(m) < 2 { + return 0 + } + n, _ := strconv.Atoi(m[1]) + if n <= 0 { + return 0 + } + return uint(n) +} + +func (b *QQBot) waitAndPushJobResult(userID string, jobID uint, cmd string) { + if b.db == nil { + return + } + var job models.OpsJob + for i := 0; i < 15; i++ { // 最多等 ~30s + time.Sleep(2 * time.Second) + if err := b.db.First(&job, jobID).Error; err != nil { + continue + } + if job.Status == "pending" || job.Status == "running" { + continue + } + break + } + if job.ID == 0 || job.Status == "pending" || job.Status == "running" { + return + } + msg := formatJobResult(b.db, jobID, cmd) + if strings.TrimSpace(msg) == "" { + return + } + _, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg}) if err != nil { - log.Printf("QQ记账失败 user=%s: %v", userID, err) - return "❌ 记账失败,请稍后重试" - } - if amount == 0 { - return "📍 没看到金额,这笔花了多少钱?" + log.Printf("QQ 推送任务结果失败: %v", err) } +} - amountYuan := float64(amount) / 100.0 - return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text) +func formatJobResult(db *gorm.DB, jobID uint, cmd string) string { + var job models.OpsJob + if err := db.First(&job, jobID).Error; err != nil { + return "" + } + if job.Runbook == "cpa_status" { + return formatCPAStatusResult(db, jobID, job.Status) + } + if job.Runbook == "cpa_usage_backup" { + return formatCPAUsageBackupResult(db, jobID) + } + if job.Runbook == "cf_zones" { + return formatCFZonesResult(db, jobID, cmd) + } + if job.Runbook == "cf_workers_list" { + return formatCFWorkersResult(db, jobID, cmd) + } + return fmt.Sprintf("📦 %s 结果:%s (job=%d)", strings.TrimSpace(cmd), job.Status, jobID) +} + +func formatCFZonesResult(db *gorm.DB, jobID uint, cmd string) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var raw string + for _, st := range steps { + if st.StepID == "list_zones" { + raw = strings.TrimSpace(st.StdoutTail) + break + } + } + if raw == "" { + return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID) + } + arr := gjson.Get(raw, "zones").Array() + if len(arr) == 0 { + arr = gjson.Get(raw, "result").Array() + } + if len(arr) == 0 { + return fmt.Sprintf("📦 %s 结果:success (job=%d)\n(no zones)", strings.TrimSpace(cmd), jobID) + } + lines := make([]string, 0, len(arr)+2) + lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID)) + limit := len(arr) + if limit > 50 { + limit = 50 + } + for i := 0; i < limit; i++ { + name := arr[i].Get("name").String() + id := arr[i].Get("id").String() + if name == "" && id == "" { + continue + } + if id != "" { + lines = append(lines, fmt.Sprintf("- %s (%s)", name, id)) + } else { + lines = append(lines, fmt.Sprintf("- %s", name)) + } + } + if len(arr) > limit { + lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit)) + } + return strings.Join(lines, "\n") +} + +func formatCFWorkersResult(db *gorm.DB, jobID uint, cmd string) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var raw string + for _, st := range steps { + if st.StepID == "list_workers" { + raw = strings.TrimSpace(st.StdoutTail) + break + } + } + if raw == "" { + return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID) + } + arr := gjson.Get(raw, "workers").Array() + if len(arr) == 0 { + arr = gjson.Get(raw, "result").Array() + } + lines := make([]string, 0, len(arr)+2) + lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID)) + if len(arr) == 0 { + lines = append(lines, "(no workers)") + return strings.Join(lines, "\n") + } + limit := len(arr) + if limit > 50 { + limit = 50 + } + for i := 0; i < limit; i++ { + name := arr[i].Get("id").String() + if name == "" { + name = arr[i].Get("name").String() + } + if name == "" { + continue + } + lines = append(lines, fmt.Sprintf("- %s", name)) + } + if len(arr) > limit { + lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit)) + } + return strings.Join(lines, "\n") +} + +func formatCPAUsageBackupResult(db *gorm.DB, jobID uint) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var raw string + for _, st := range steps { + if st.StepID == "export_and_package" { + raw = strings.TrimSpace(st.StdoutTail) + break + } + } + if raw == "" { + return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID) + } + backup := "" + for _, line := range strings.Split(raw, "\n") { + if strings.HasPrefix(line, "backup=") { + backup = strings.TrimSpace(strings.TrimPrefix(line, "backup=")) + break + } + } + if backup == "" { + return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID) + } + file := path.Base(backup) + return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)\n📦 备份文件:%s\n📁 路径:%s", jobID, file, path.Dir(backup)+"/") +} + +func formatCPAStatusResult(db *gorm.DB, jobID uint, status string) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var svc, usage string + for _, st := range steps { + if st.StepID == "service_status" { + svc = strings.TrimSpace(st.StdoutTail) + } + if st.StepID == "usage_snapshot" { + usage = strings.TrimSpace(st.StdoutTail) + } + } + tr := gjson.Get(usage, "usage.total_requests").String() + tt := gjson.Get(usage, "usage.total_tokens").String() + if tr == "" { + tr = "-" + } + if tt == "" { + tt = "-" + } + return fmt.Sprintf("✅ /cpa status 完成 (job=%d)\nservice=%s\nrequests=%s\ntokens=%s", jobID, svc, tr, tt) } func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler { diff --git a/internal/qq/sub_scheduler.go b/internal/qq/sub_scheduler.go new file mode 100644 index 0000000..f4df542 --- /dev/null +++ b/internal/qq/sub_scheduler.go @@ -0,0 +1,308 @@ +package qq + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strings" + "time" + + "ops-assistant/models" + + "github.com/tencent-connect/botgo/dto" +) + +type subRemindItem struct { + ID string `json:"id"` + Name string `json:"name"` + Category string `json:"category"` + Account string `json:"account"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + DueDate string `json:"due_date"` + CycleUnit string `json:"cycle_unit"` + CycleValue int `json:"cycle_value"` + Remind []int `json:"remind_days"` +} + +type subListResp struct { + Success bool `json:"success"` + Data []subRemindItem `json:"data"` + Error string `json:"error"` +} + +type subNotifySettings struct { + APIBase string + Token string + NotifyQQUserID string + NotifyGroupID string + NotifyWeComWebhook string + NotifyTime string +} + +func (b *QQBot) StartSubScheduler(ctx context.Context, hour, minute int) { + if b.db == nil { + return + } + go func() { + b.trySendTestOnce(ctx) + for { + next := nextRunTime(hour, minute) + t := time.NewTimer(time.Until(next)) + select { + case <-ctx.Done(): + t.Stop() + return + case <-t.C: + b.runSubNotifyOnce(ctx) + } + } + }() +} + +func (b *QQBot) trySendTestOnce(ctx context.Context) { + if b.db == nil { + log.Printf("[sub_notify] test skip: db nil") + return + } + var s models.AppSetting + if err := b.db.Where("key = ?", "sub_notify_test_once").First(&s).Error; err != nil { + log.Printf("[sub_notify] test skip: setting not found") + return + } + if strings.TrimSpace(s.Value) != "1" { + log.Printf("[sub_notify] test skip: flag=%s", strings.TrimSpace(s.Value)) + return + } + cfg, err := b.loadSubNotifySettings() + if err != nil { + log.Printf("[sub_notify] test skip: load settings err=%v", err) + return + } + if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" { + log.Printf("[sub_notify] test skip: notify target empty") + return + } + for i := 0; i < 10; i++ { + if b.api != nil { + break + } + select { + case <-ctx.Done(): + log.Printf("[sub_notify] test skip: ctx done") + return + case <-time.After(1 * time.Second): + } + } + if b.api == nil { + log.Printf("[sub_notify] test skip: api nil") + return + } + msg := fmt.Sprintf("✅ 订阅提醒测试 %s", time.Now().Format("01-02 15:04")) + if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" { + if err := sendWeCom(cfg.NotifyWeComWebhook, msg); err == nil { + s.Value = "0" + _ = b.db.Save(&s).Error + log.Printf("[sub_notify] test send ok wecom") + } else { + log.Printf("[sub_notify] test send failed wecom err=%v", err) + } + return + } + if strings.TrimSpace(cfg.NotifyGroupID) != "" { + if err := b.sendGroup(cfg.NotifyGroupID, msg); err == nil { + s.Value = "0" + _ = b.db.Save(&s).Error + log.Printf("[sub_notify] test send ok group=%s", cfg.NotifyGroupID) + } else { + log.Printf("[sub_notify] test send failed group=%s err=%v", cfg.NotifyGroupID, err) + } + return + } + if err := b.sendC2C(cfg.NotifyQQUserID, msg); err == nil { + s.Value = "0" + _ = b.db.Save(&s).Error + log.Printf("[sub_notify] test send ok user=%s", cfg.NotifyQQUserID) + } else { + log.Printf("[sub_notify] test send failed user=%s err=%v", cfg.NotifyQQUserID, err) + } +} + +func nextRunTime(hour, minute int) time.Time { + now := time.Now() + loc := now.Location() + next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc) + if !next.After(now) { + next = next.Add(24 * time.Hour) + } + return next +} + +func (b *QQBot) runSubNotifyOnce(ctx context.Context) { + cfg, err := b.loadSubNotifySettings() + if err != nil { + return + } + if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" { + return + } + url := fmt.Sprintf("%s/api/subscriptions?user_id=1&channel=qq", strings.TrimRight(cfg.APIBase, "/")) + body, status, err := doSubRequest("GET", url, nil, cfg.Token) + if err != nil || status >= 400 { + return + } + var out subListResp + if err := json.Unmarshal([]byte(body), &out); err != nil || !out.Success { + return + } + if len(out.Data) == 0 { + return + } + now := time.Now() + today := now.Format("2006-01-02") + for _, it := range out.Data { + if it.DueDate == "" { + continue + } + due, err := time.ParseInLocation("2006-01-02", it.DueDate, now.Location()) + if err != nil { + continue + } + daysLeft := int(due.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())).Hours() / 24) + if !shouldRemind(it.Remind, daysLeft) { + continue + } + targetID := cfg.NotifyQQUserID + if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" { + targetID = "wecom" + } else if strings.TrimSpace(cfg.NotifyGroupID) != "" { + targetID = cfg.NotifyGroupID + } + if b.hasNotified("qqbot_official", targetID, it.ID, today, daysLeft) { + continue + } + msg := formatSubReminder(it, daysLeft) + if msg == "" { + continue + } + if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" { + _ = sendWeCom(cfg.NotifyWeComWebhook, msg) + } else if strings.TrimSpace(cfg.NotifyGroupID) != "" { + _ = b.sendGroup(cfg.NotifyGroupID, msg) + } else { + _ = b.sendC2C(cfg.NotifyQQUserID, msg) + } + b.markNotified("qqbot_official", targetID, it.ID, today, daysLeft) + } +} + +func (b *QQBot) loadSubNotifySettings() (*subNotifySettings, error) { + if b.db == nil { + return nil, fmt.Errorf("db 未初始化") + } + get := func(key string) string { + var s models.AppSetting + if err := b.db.Where("key = ?", key).First(&s).Error; err == nil { + return strings.TrimSpace(s.Value) + } + return "" + } + cfg := &subNotifySettings{ + APIBase: get("sub_api_base"), + Token: get("sub_api_token"), + NotifyQQUserID: get("sub_notify_qq_user_id"), + NotifyGroupID: get("sub_notify_group_id"), + NotifyWeComWebhook: get("sub_notify_wecom_webhook"), + NotifyTime: get("sub_notify_time"), + } + if cfg.APIBase == "" { + return nil, fmt.Errorf("sub_api_base 未配置") + } + if cfg.Token == "" { + return nil, fmt.Errorf("sub_api_token 未配置") + } + return cfg, nil +} + +func shouldRemind(remind []int, daysLeft int) bool { + for _, d := range remind { + if d == daysLeft { + return true + } + } + return false +} + +func (b *QQBot) hasNotified(platform, userID, subID, date string, daysLeft int) bool { + var row models.SubNotifyLog + if err := b.db.Where("platform=? AND external_user_id=? AND sub_id=? AND notify_date=? AND days_left=?", platform, userID, subID, date, daysLeft).First(&row).Error; err == nil { + return true + } + return false +} + +func (b *QQBot) markNotified(platform, userID, subID, date string, daysLeft int) { + _ = b.db.Create(&models.SubNotifyLog{Platform: platform, ExternalUserID: userID, SubID: subID, NotifyDate: date, DaysLeft: daysLeft, CreatedAt: time.Now()}).Error +} + +func formatSubReminder(it subRemindItem, daysLeft int) string { + prefix := "⏰" + if daysLeft < 0 { + prefix = "⚠️" + } + return fmt.Sprintf("%s 订阅提醒:%s | 分类:%s | 到期:%s | 剩余:%d天 | 金额:%.2f %s", prefix, it.Name, it.Category, it.DueDate, daysLeft, it.Amount, it.Currency) +} + +func (b *QQBot) sendC2C(userID, msg string) error { + if b.api == nil { + log.Printf("[sub_notify] skip send: api nil") + return nil + } + _, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg}) + if err != nil { + log.Printf("[sub_notify] send failed user=%s err=%v", userID, err) + return err + } + log.Printf("[sub_notify] send ok user=%s", userID) + return nil +} + +func (b *QQBot) sendGroup(groupID, msg string) error { + if b.api == nil { + log.Printf("[sub_notify] skip send group: api nil") + return nil + } + _, err := b.api.PostGroupMessage(context.Background(), groupID, dto.MessageToCreate{Content: msg}) + if err != nil { + log.Printf("[sub_notify] send failed group=%s err=%v", groupID, err) + return err + } + log.Printf("[sub_notify] send ok group=%s", groupID) + return nil +} + +func sendWeCom(webhook, msg string) error { + payload := map[string]any{ + "msgtype": "text", + "text": map[string]any{"content": msg}, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, webhook, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("wecom status %d", resp.StatusCode) + } + return nil +} diff --git a/internal/qq/sub_state.go b/internal/qq/sub_state.go new file mode 100644 index 0000000..2552d2e --- /dev/null +++ b/internal/qq/sub_state.go @@ -0,0 +1,323 @@ +package qq + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "ops-assistant/models" +) + +type subSessionPayload struct { + Step string `json:"step"` + Temp map[string]string `json:"temp"` +} + +type subAPISettings struct { + APIBase string + Token string +} + +const ( + subStepName = "WAIT_NAME" + subStepCategory = "WAIT_CATEGORY" + subStepAcct = "WAIT_ACCOUNT" + subStepAmount = "WAIT_AMOUNT" + subStepCurrency = "WAIT_CURRENCY" + subStepDate = "WAIT_DATE" + subStepCycleUnit = "WAIT_CYCLE_UNIT" + subStepCycleValue = "WAIT_CYCLE_VALUE" +) + +var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + +func (b *QQBot) handleSubFlow(userID string, text string) (string, bool) { + raw := strings.TrimSpace(text) + if !strings.HasPrefix(raw, "/sub") { + if raw == "/cancel" { + if b.hasSubSession(userID) { + b.clearSubSession(userID) + return "✅ 已取消订阅新增流程", true + } + return "", false + } + if !b.hasSubSession(userID) { + return "", false + } + } else { + if raw == "/sub" || strings.HasPrefix(raw, "/sub help") { + if b.opsSvc != nil { + if handled, out := b.opsSvc.Handle(DefaultUserID, raw); handled { + return out, true + } + } + return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del \n- /sub cancel(或 /cancel)取消新增流程", true + } + if raw == "/sub cancel" { + if b.hasSubSession(userID) { + b.clearSubSession(userID) + return "✅ 已取消订阅新增流程", true + } + return "", true + } + if raw == "/sub add" || strings.HasPrefix(raw, "/sub add ") { + b.setSubSession(userID, &subSessionPayload{Step: subStepName, Temp: map[string]string{}}) + return "1️⃣ 输入订阅名称(例如:Netflix)", true + } + return "", false + } + + sess := b.getSubSession(userID) + if sess == nil { + return "", false + } + msg := raw + if msg == "" { + return "", true + } + if sess.Temp == nil { + sess.Temp = map[string]string{} + } + + switch sess.Step { + case subStepName: + sess.Temp["name"] = msg + sess.Step = subStepCategory + b.setSubSession(userID, sess) + return "2️⃣ 输入分类(例如:通信/视频/游戏/工具/其他,可 /skip=未分类)", true + case subStepCategory: + if msg == "/skip" { + sess.Temp["category"] = "未分类" + } else { + sess.Temp["category"] = msg + } + if strings.TrimSpace(sess.Temp["category"]) == "" { + return "❌ 分类不能为空,请重新输入(可 /skip)", true + } + sess.Step = subStepAcct + b.setSubSession(userID, sess) + return "3️⃣ 输入账号/备注(可选,回复 /skip 跳过)", true + case subStepAcct: + if msg == "/skip" { + sess.Temp["account"] = "" + } else { + sess.Temp["account"] = msg + } + sess.Step = subStepAmount + b.setSubSession(userID, sess) + return "4️⃣ 输入金额(数字,例如 9.99)", true + case subStepAmount: + amt, err := strconv.ParseFloat(msg, 64) + if err != nil || amt < 0 { + return "❌ 金额格式错误,请输入数字(例如 9.99)", true + } + sess.Temp["amount"] = fmt.Sprintf("%.2f", amt) + sess.Step = subStepCurrency + b.setSubSession(userID, sess) + return "5️⃣ 输入币种(CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD,或 /skip 默认 CNY)", true + case subStepCurrency: + cur := strings.ToUpper(msg) + if msg == "/skip" { + cur = "CNY" + } + if !isAllowedCurrency(cur) { + return "❌ 币种不支持,请输入 CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD", true + } + sess.Temp["currency"] = cur + sess.Step = subStepDate + b.setSubSession(userID, sess) + return "6️⃣ 输入到期日(YYYY-MM-DD)", true + case subStepDate: + if !dateRe.MatchString(msg) { + return "❌ 日期格式错误,请用 YYYY-MM-DD", true + } + sess.Temp["due_date"] = msg + sess.Step = subStepCycleUnit + b.setSubSession(userID, sess) + return "7️⃣ 输入周期单位(day/month/year)", true + case subStepCycleUnit: + unit := strings.ToLower(msg) + if !isAllowedCycleUnit(unit) { + return "❌ 周期单位不支持,请输入 day/month/year", true + } + sess.Temp["cycle_unit"] = unit + sess.Step = subStepCycleValue + b.setSubSession(userID, sess) + return "8️⃣ 输入周期数量(数字)", true + case subStepCycleValue: + cv, err := strconv.Atoi(msg) + if err != nil || cv <= 0 { + return "❌ 周期数量必须是正整数", true + } + sess.Temp["cycle_value"] = strconv.Itoa(cv) + b.clearSubSession(userID) + return b.subCreate(sess) + default: + b.clearSubSession(userID) + return "❌ 会话已重置,请重新发送 /sub add", true + } +} + +func (b *QQBot) subCreate(sess *subSessionPayload) (string, bool) { + cfg, err := b.loadSubSettings() + if err != nil { + return "❌ 未配置 sub_api_base/sub_api_token", true + } + payload := map[string]any{ + "user_id": "1", + "channel": "qq", + "name": sess.Temp["name"], + "category": sess.Temp["category"], + "account": sess.Temp["account"], + "amount": toFloat(sess.Temp["amount"]), + "currency": sess.Temp["currency"], + "due_date": sess.Temp["due_date"], + "cycle_unit": sess.Temp["cycle_unit"], + "cycle_value": toInt(sess.Temp["cycle_value"]), + } + body, _ := json.Marshal(payload) + url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions" + resp, status, err := doSubRequest(http.MethodPost, url, body, cfg.Token) + if err != nil { + return "❌ 创建失败: " + err.Error(), true + } + if status >= 400 { + return fmt.Sprintf("❌ 创建失败: http %d %s", status, strings.TrimSpace(resp)), true + } + return "✅ 添加成功", true +} + +func (b *QQBot) loadSubSettings() (*subAPISettings, error) { + if b.db == nil { + return nil, fmt.Errorf("db nil") + } + var base models.AppSetting + if err := b.db.Where("key = ?", "sub_api_base").First(&base).Error; err != nil { + return nil, err + } + var token models.AppSetting + if err := b.db.Where("key = ?", "sub_api_token").First(&token).Error; err != nil { + return nil, err + } + apiBase := strings.TrimSpace(base.Value) + apiToken := strings.TrimSpace(token.Value) + if apiBase == "" || apiToken == "" { + return nil, fmt.Errorf("empty settings") + } + return &subAPISettings{APIBase: apiBase, Token: apiToken}, nil +} + +func (b *QQBot) hasSubSession(userID string) bool { + if b.db == nil { + return false + } + var st models.SubSession + if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil { + return false + } + return strings.TrimSpace(st.Step) != "" +} + +func (b *QQBot) getSubSession(userID string) *subSessionPayload { + if b.db == nil { + return nil + } + var st models.SubSession + if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil { + return nil + } + var payload subSessionPayload + if err := json.Unmarshal([]byte(st.TempJSON), &payload.Temp); err != nil { + payload.Temp = map[string]string{} + } + payload.Step = st.Step + return &payload +} + +func (b *QQBot) setSubSession(userID string, sess *subSessionPayload) { + if b.db == nil { + return + } + tmp, _ := json.Marshal(sess.Temp) + var row models.SubSession + if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&row).Error; err == nil { + row.Step = sess.Step + row.TempJSON = string(tmp) + _ = b.db.Save(&row).Error + return + } + _ = b.db.Create(&models.SubSession{ + UserID: userID, + Platform: "qqbot_official", + Step: sess.Step, + TempJSON: string(tmp), + }).Error +} + +func (b *QQBot) clearSubSession(userID string) { + if b.db == nil { + return + } + _ = b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").Delete(&models.SubSession{}).Error +} + +func doSubRequest(method, url string, body []byte, token string) (string, int, error) { + req, err := http.NewRequest(method, url, bytes.NewReader(body)) + if err != nil { + return "", 0, err + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return string(data), resp.StatusCode, nil +} + +func toInt(s string) int { + if s == "" { + return 0 + } + v, _ := strconv.Atoi(s) + return v +} + +func toFloat(s string) float64 { + if s == "" { + return 0 + } + v, _ := strconv.ParseFloat(s, 64) + return v +} + +func isAllowedCurrency(c string) bool { + switch strings.ToUpper(c) { + case "CNY", "USD", "EUR", "GBP", "HKD", "JPY", "MYR", "SGD": + return true + default: + return false + } +} + +func isAllowedCycleUnit(u string) bool { + switch strings.ToLower(u) { + case "day", "month", "year": + return true + default: + return false + } +} diff --git a/internal/service/finance.go b/internal/service/finance.go index 5cf303e..17407ac 100644 --- a/internal/service/finance.go +++ b/internal/service/finance.go @@ -6,7 +6,7 @@ import ( "strconv" "time" - "xiaji-go/models" + "ops-assistant/models" "github.com/yanyiwu/gojieba" "gorm.io/gorm" diff --git a/internal/web/apiresp.go b/internal/web/apiresp.go new file mode 100644 index 0000000..35143e1 --- /dev/null +++ b/internal/web/apiresp.go @@ -0,0 +1,17 @@ +package web + +import "github.com/gin-gonic/gin" + +type apiResp struct { + Code string `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func respondOK(c *gin.Context, message string, data any) { + c.JSON(200, apiResp{Code: "OK", Message: message, Data: data}) +} + +func respondErr(c *gin.Context, status int, code, message string) { + c.JSON(status, apiResp{Code: code, Message: message}) +} diff --git a/internal/web/server.go b/internal/web/server.go index 96bf63a..7f2a2ad 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -4,9 +4,11 @@ import ( "context" "crypto/hmac" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" + "io" "net" "net/http" "path/filepath" @@ -88,6 +90,18 @@ type cfSettingsReq struct { APIToken string `json:"api_token"` } +type cfdavSettingsReq struct { + APIBase string `json:"api_base"` + AdminUser string `json:"admin_user"` + AdminPass string `json:"admin_pass"` +} + +type cfdavSettings struct { + APIBase string + AdminUser string + AdminPass string +} + type aiSettingsReq struct { Enabled *bool `json:"enabled"` BaseURL string `json:"base_url"` @@ -352,6 +366,12 @@ func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) { // Cloudflare settings auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet) auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch) + // CFDAV settings + auth.GET("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsGet) + auth.PATCH("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsPatch) + auth.GET("/api/v1/admin/cfdav/users", s.handleCFDAVUsersList) + auth.POST("/api/v1/admin/cfdav/users", s.handleCFDAVUsersCreate) + auth.DELETE("/api/v1/admin/cfdav/users/:id", s.handleCFDAVUsersDelete) // AI settings auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet) auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch) @@ -447,6 +467,8 @@ func (s *WebServer) RegisterRoutes(r *gin.Engine) { auth.GET("/ops", s.handleOpsPage) auth.GET("/cpa", s.handleCPASettingsPage) auth.GET("/cf", s.handleCFSettingsPage) + auth.GET("/cfdav", s.handleCFDAVSettingsPage) + auth.GET("/modules", s.handleModulesPage) // AI 配置页临时隐藏(保留后端接口) s.registerAPIV1Routes(auth) @@ -565,6 +587,24 @@ func (s *WebServer) handleCFSettingsPage(c *gin.Context) { s.renderPage(c, "cf_settings.html", u, nil) } +func (s *WebServer) handleCFDAVSettingsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "cfdav_settings.html", u, nil) +} + +func (s *WebServer) handleModulesPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "modules.html", u, nil) +} + func (s *WebServer) handleAISettingsPage(c *gin.Context) { u := currentUser(c) if u == nil || !s.hasPermission(u, "settings.flags.read") { @@ -645,7 +685,7 @@ func (s *WebServer) handleCPASettingsPatch(c *gin.Context) { } _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error } - + s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success") respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}}) } @@ -720,6 +760,233 @@ func (s *WebServer) handleCFSettingsPatch(c *gin.Context) { respondOK(c, "success", gin.H{"ok": true}) } +func (s *WebServer) handleCFDAVSettingsGet(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"} + out := map[string]string{} + for _, k := range keys { + var sset models.AppSetting + if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { + out[k] = sset.Value + } else { + out[k] = "" + } + } + respondOK(c, "ok", gin.H{"settings": out}) +} + +func (s *WebServer) handleCFDAVSettingsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req cfdavSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + if req.APIBase != "" { + key := "cfdav_api_base" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.APIBase, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.APIBase + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + if req.AdminUser != "" { + key := "cfdav_admin_user" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.AdminUser, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.AdminUser + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + if req.AdminPass != "" { + key := "cfdav_admin_pass" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.AdminPass, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.AdminPass + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + respondOK(c, "success", gin.H{"ok": true}) +} + +func (s *WebServer) loadCFDAVSettings() (*cfdavSettings, error) { + keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"} + out := &cfdavSettings{} + for _, k := range keys { + var sset models.AppSetting + val := "" + if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil { + val = sset.Value + } + switch k { + case "cfdav_api_base": + out.APIBase = val + case "cfdav_admin_user": + out.AdminUser = val + case "cfdav_admin_pass": + out.AdminPass = val + } + } + if strings.TrimSpace(out.AdminUser) == "" || strings.TrimSpace(out.AdminPass) == "" { + return nil, fmt.Errorf("cfdav admin 账号未配置") + } + return out, nil +} + +func (s *WebServer) doCFDAVRequest(method, url string, body []byte, settings *cfdavSettings) (string, int, error) { + var reader io.Reader + if body != nil { + reader = strings.NewReader(string(body)) + } + req, err := http.NewRequest(method, url, reader) + if err != nil { + return "", 0, err + } + auth := settings.AdminUser + ":" + settings.AdminPass + b64 := base64.StdEncoding.EncodeToString([]byte(auth)) + req.Header.Set("Authorization", "Basic "+b64) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", resp.StatusCode, err + } + return string(data), resp.StatusCode, nil +} + +func (s *WebServer) handleCFDAVUsersList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + settings, err := s.loadCFDAVSettings() + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + if strings.TrimSpace(settings.APIBase) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置") + return + } + url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users" + resp, status, err := s.doCFDAVRequest(http.MethodGet, url, nil, settings) + if err != nil { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error()) + return + } + if status >= 400 { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp) + return + } + var out map[string]any + if err := json.Unmarshal([]byte(resp), &out); err != nil { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败") + return + } + respondOK(c, "ok", out) +} + +func (s *WebServer) handleCFDAVUsersCreate(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + settings, err := s.loadCFDAVSettings() + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + if strings.TrimSpace(settings.APIBase) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置") + return + } + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "读取请求失败") + return + } + url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users" + resp, status, err := s.doCFDAVRequest(http.MethodPost, url, payload, settings) + if err != nil { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error()) + return + } + if status >= 400 { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp) + return + } + var out map[string]any + if err := json.Unmarshal([]byte(resp), &out); err != nil { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败") + return + } + s.writeAuditResult(u.UserID, "cfdav.user.create", "cfdav_user", "*", "", "", "", "success") + respondOK(c, "ok", out) +} + +func (s *WebServer) handleCFDAVUsersDelete(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + settings, err := s.loadCFDAVSettings() + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + if strings.TrimSpace(settings.APIBase) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置") + return + } + id := strings.TrimSpace(c.Param("id")) + if id == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id required") + return + } + url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users/" + id + resp, status, err := s.doCFDAVRequest(http.MethodDelete, url, nil, settings) + if err != nil { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error()) + return + } + if status >= 400 { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp) + return + } + var out map[string]any + if err := json.Unmarshal([]byte(resp), &out); err != nil { + respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败") + return + } + s.writeAuditResult(u.UserID, "cfdav.user.delete", "cfdav_user", id, "", "", "", "success") + respondOK(c, "ok", out) +} + func (s *WebServer) handleAISettingsGet(c *gin.Context) { u := currentUser(c) if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { @@ -1716,6 +1983,8 @@ func (s *WebServer) handleModulesList(c *gin.Context) { {Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"}, {Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"}, {Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"}, + {Module: "cfdav", DisplayName: "cfdav 管理", FlagKey: "enable_module_cfdav"}, + {Module: "sub", DisplayName: "订阅管理", FlagKey: "enable_module_sub"}, } for i := range items { var ff models.FeatureFlag @@ -1740,6 +2009,10 @@ func (s *WebServer) handleModuleToggle(c *gin.Context) { flagKey = "enable_module_cf" case "mail": flagKey = "enable_module_mail" + case "cfdav": + flagKey = "enable_module_cfdav" + case "sub": + flagKey = "enable_module_sub" default: respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module") return @@ -1797,7 +2070,7 @@ func (s *WebServer) handleDashboardOverview(c *gin.Context) { Module string `json:"module"` Enabled bool `json:"enabled"` } - mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}} + mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}, {Module: "cfdav"}, {Module: "sub"}} for i := range mods { flagKey := "enable_module_" + mods[i].Module var ff models.FeatureFlag @@ -1900,7 +2173,7 @@ func (s *WebServer) handleDashboardSummary(c *gin.Context) { s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed) s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success) - mods := map[string]bool{"cpa": false, "cf": false, "mail": false} + mods := map[string]bool{"cpa": false, "cf": false, "mail": false, "cfdav": false, "sub": false} for k := range mods { var ff models.FeatureFlag if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil { diff --git a/models/models.go b/models/models.go index 9ddeb1e..9a03128 100644 --- a/models/models.go +++ b/models/models.go @@ -37,6 +37,45 @@ type FeatureFlag struct { UpdatedAt time.Time `json:"updated_at"` } +// OpsTarget 运维目标主机配置 +type OpsTarget struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"uniqueIndex;size:64" json:"name"` + Host string `gorm:"size:128" json:"host"` + Port int `gorm:"default:22" json:"port"` + User string `gorm:"size:64" json:"user"` + Enabled bool `gorm:"default:true" json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// FeatureFlagHistory 开关变更历史 +type AppSetting struct { + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"uniqueIndex;size:100" json:"key"` + Value string `gorm:"type:text" json:"value"` + UpdatedBy int64 `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SubSession 订阅新增流程会话 +// platform: qqbot_official/telegram/feishu +// user_id: 外部平台用户 ID +// temp_json: 临时收集字段 +// step: WAIT_NAME/WAIT_ACCOUNT/WAIT_DATE/WAIT_CYCLE/WAIT_CUSTOM_DAYS +// 仅用于引导式新增流程 +// 统一由 Bot 端维护 +type SubSession struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID string `gorm:"size:64;uniqueIndex:idx_sub_session_user_platform" json:"user_id"` + Platform string `gorm:"size:32;uniqueIndex:idx_sub_session_user_platform" json:"platform"` + Step string `gorm:"size:32" json:"step"` + TempJSON string `gorm:"type:text" json:"temp_json"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // FeatureFlagHistory 开关变更历史 type FeatureFlagHistory struct { ID uint `gorm:"primaryKey" json:"id"` @@ -88,6 +127,53 @@ type MessageDedup struct { ProcessedAt time.Time `json:"processed_at"` } +// SubNotifyLog 订阅提醒去重日志(避免同一天重复提醒) +type SubNotifyLog struct { + ID uint `gorm:"primaryKey" json:"id"` + Platform string `gorm:"size:32;index:idx_sub_notify_unique,unique" json:"platform"` + ExternalUserID string `gorm:"size:64;index:idx_sub_notify_unique,unique" json:"external_user_id"` + SubID string `gorm:"size:64;index:idx_sub_notify_unique,unique" json:"sub_id"` + NotifyDate string `gorm:"size:10;index:idx_sub_notify_unique,unique" json:"notify_date"` // YYYY-MM-DD + DaysLeft int `gorm:"index:idx_sub_notify_unique,unique" json:"days_left"` + CreatedAt time.Time `json:"created_at"` +} + +// OpsJob 运维命令执行任务 +type OpsJob struct { + ID uint `gorm:"primaryKey" json:"id"` + Command string `gorm:"size:255;index" json:"command"` + Runbook string `gorm:"size:128;index" json:"runbook"` + Operator int64 `gorm:"index" json:"operator"` + Target string `gorm:"size:128;index" json:"target"` + RiskLevel string `gorm:"size:16" json:"risk_level"` + RequestID string `gorm:"size:100;index" json:"request_id"` + ConfirmHash string `gorm:"size:80" json:"confirm_hash"` + InputJSON string `gorm:"type:text" json:"input_json"` + Status string `gorm:"size:20;index" json:"status"` // pending|running|success|failed|cancelled + CancelNote string `gorm:"size:255" json:"cancel_note"` + Summary string `gorm:"size:500" json:"summary"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// OpsJobStep 任务步骤日志 +type OpsJobStep struct { + ID uint `gorm:"primaryKey" json:"id"` + JobID uint `gorm:"index" json:"job_id"` + StepID string `gorm:"size:80" json:"step_id"` + Action string `gorm:"size:80" json:"action"` + Status string `gorm:"size:20;index" json:"status"` // running|success|failed|skipped + RC int `json:"rc"` + StdoutTail string `gorm:"type:text" json:"stdout_tail"` + StderrTail string `gorm:"type:text" json:"stderr_tail"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // AmountYuan 返回元为单位的金额(显示用) func (t *Transaction) AmountYuan() float64 { return float64(t.Amount) / 100.0 @@ -100,6 +186,12 @@ func seedDefaultFeatureFlags(db *gorm.DB) error { {Key: "allow_export_all_users", Enabled: false, RiskLevel: "high", Description: "允许导出全量用户账本数据", RequireReason: true}, {Key: "allow_manual_role_grant", Enabled: false, RiskLevel: "medium", Description: "允许人工授予角色", RequireReason: true}, {Key: "allow_bot_admin_commands", Enabled: false, RiskLevel: "medium", Description: "允许 Bot 侧执行管理命令", RequireReason: true}, + {Key: "allow_ops_restore", Enabled: false, RiskLevel: "high", Description: "允许执行 usage restore 高风险动作", RequireReason: true}, + {Key: "enable_module_cpa", Enabled: true, RiskLevel: "low", Description: "启用 CPA 模块命令入口", RequireReason: false}, + {Key: "enable_module_cf", Enabled: false, RiskLevel: "medium", Description: "启用 CF 模块命令入口", RequireReason: true}, + {Key: "enable_module_mail", Enabled: false, RiskLevel: "medium", Description: "启用 Mail 模块命令入口", RequireReason: true}, + {Key: "enable_module_cfdav", Enabled: false, RiskLevel: "low", Description: "启用 cfdav 模块入口", RequireReason: false}, + {Key: "enable_module_sub", Enabled: false, RiskLevel: "low", Description: "启用 sub 订阅模块入口", RequireReason: false}, } for _, ff := range defaults { @@ -110,6 +202,35 @@ func seedDefaultFeatureFlags(db *gorm.DB) error { return nil } +func seedDefaultAppSettings(db *gorm.DB) error { + defaults := []AppSetting{ + {Key: "cpa_management_token", Value: ""}, + {Key: "cf_account_id", Value: ""}, + {Key: "cf_api_token", Value: ""}, + {Key: "ai_enabled", Value: "false"}, + {Key: "ai_base_url", Value: ""}, + {Key: "ai_api_key", Value: ""}, + {Key: "ai_model", Value: ""}, + {Key: "ai_timeout_seconds", Value: "15"}, + {Key: "cfdav_api_base", Value: ""}, + {Key: "cfdav_admin_user", Value: ""}, + {Key: "cfdav_admin_pass", Value: ""}, + {Key: "sub_api_base", Value: ""}, + {Key: "sub_api_token", Value: ""}, + {Key: "sub_notify_qq_user_id", Value: ""}, + {Key: "sub_notify_group_id", Value: ""}, + {Key: "sub_notify_wecom_webhook", Value: ""}, + {Key: "sub_notify_time", Value: "09:00"}, + {Key: "sub_notify_test_once", Value: ""}, + } + for _, s := range defaults { + if err := db.Where("key = ?", s.Key).FirstOrCreate(&s).Error; err != nil { + return err + } + } + return nil +} + func seedDefaultChannels(db *gorm.DB) error { defaults := []ChannelConfig{ {Platform: "qqbot_official", Name: "QQ 官方 Bot", Enabled: false, Status: "disabled", ConfigJSON: "{}", SecretJSON: "{}", DraftConfigJSON: "{}", DraftSecretJSON: "{}"}, @@ -135,6 +256,11 @@ func Migrate(db *gorm.DB) error { &ChannelConfig{}, &AuditLog{}, &MessageDedup{}, + &SubNotifyLog{}, + &OpsTarget{}, + &OpsJob{}, + &OpsJobStep{}, + &AppSetting{}, ); err != nil { return err } @@ -146,6 +272,9 @@ func Migrate(db *gorm.DB) error { if err := seedDefaultChannels(db); err != nil { return err } + if err := seedDefaultAppSettings(db); err != nil { + return err + } // 检查是否已有关键词数据 var count int64 diff --git a/runbooks/cf_dns_add.yaml b/runbooks/cf_dns_add.yaml new file mode 100644 index 0000000..17b76a7 --- /dev/null +++ b/runbooks/cf_dns_add.yaml @@ -0,0 +1,18 @@ +version: 1 +name: cf_dns_add +description: 新增 DNS 记录(按 name/content) +steps: + - id: add_dns + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_ACCOUNT_ID=${env_cf_account_id} CF_API_TOKEN=${env_cf_api_token} INPUT_NAME=${env.INPUT_NAME} INPUT_CONTENT=${env.INPUT_CONTENT} INPUT_TYPE=${env.INPUT_TYPE} INPUT_PROXIED=${env.INPUT_PROXIED} python3 -c 'import base64; exec(base64.b64decode(\"aW1wb3J0IG9zLHJlcXVlc3RzLGpzb24KbmFtZT1vcy5nZXRlbnYoJ0lOUFVUX05BTUUnLCcnKQpjb250ZW50PW9zLmdldGVudignSU5QVVRfQ09OVEVOVCcsJycpCnJlY190eXBlPW9zLmdldGVudignSU5QVVRfVFlQRScsJ0EnKQpwcm94aWVkPW9zLmdldGVudignSU5QVVRfUFJPWElFRCcsJ2ZhbHNlJykubG93ZXIoKT09J3RydWUnCmFjY291bnQ9b3MuZ2V0ZW52KCdDRl9BQ0NPVU5UX0lEJywnJykKdG9rZW49b3MuZ2V0ZW52KCdDRl9BUElfVE9LRU4nLCcnKQpoZWFkZXJzPXsnQXV0aG9yaXphdGlvbic6J0JlYXJlciAnK3Rva2VuLCdDb250ZW50LVR5cGUnOidhcHBsaWNhdGlvbi9qc29uJ30KcmVzcD1yZXF1ZXN0cy5nZXQoJ2h0dHBzOi8vYXBpLmNsb3VkZmxhcmUuY29tL2NsaWVudC92NC96b25lcycsIGhlYWRlcnM9aGVhZGVycywgcGFyYW1zPXsnYWNjb3VudC5pZCc6YWNjb3VudCwncGVyX3BhZ2UnOjIwMH0sIHRpbWVvdXQ9MTUpCmRhdGE9cmVzcC5qc29uKCkKem9uZT1Ob25lCmZvciB6IGluIGRhdGEuZ2V0KCdyZXN1bHQnLFtdKToKICAgIHpuPXouZ2V0KCduYW1lJywnJykKICAgIGlmIG5hbWU9PXpuIG9yIG5hbWUuZW5kc3dpdGgoJy4nK3puKToKICAgICAgICB6b25lPXo7IGJyZWFrCmlmIG5vdCB6b25lOgogICAgcHJpbnQoanNvbi5kdW1wcyh7J3N1Y2Nlc3MnOkZhbHNlLCdzdGFnZSc6J21hdGNoX3pvbmUnLCdlcnJvcnMnOlsnem9uZV9ub3RfZm91bmQnXSwnbmFtZSc6bmFtZX0sIGVuc3VyZV9hc2NpaT1GYWxzZSkpOyByYWlzZSBTeXN0ZW1FeGl0KDIpCnpvbmVfaWQ9em9uZS5nZXQoJ2lkJykKcGF5bG9hZD17J3R5cGUnOnJlY190eXBlLCduYW1lJzpuYW1lLCdjb250ZW50Jzpjb250ZW50LCdwcm94aWVkJzpwcm94aWVkfQpyZXNwMj1yZXF1ZXN0cy5wb3N0KCdodHRwczovL2FwaS5jbG91ZGZsYXJlLmNvbS9jbGllbnQvdjQvem9uZXMvJyt6b25lX2lkKycvZG5zX3JlY29yZHMnLCBoZWFkZXJzPWhlYWRlcnMsIGpzb249cGF5bG9hZCwgdGltZW91dD0xNSkKcHJpbnQocmVzcDIudGV4dCkK\"), {})'" + - id: assert + action: assert.json + on_fail: stop + with: + source_step: add_dns + expect_success: true + required_paths: + - "success" diff --git a/runbooks/cf_dns_del.yaml b/runbooks/cf_dns_del.yaml new file mode 100644 index 0000000..2c47e8d --- /dev/null +++ b/runbooks/cf_dns_del.yaml @@ -0,0 +1,18 @@ +version: 1 +name: cf_dns_del +description: 删除 DNS 记录(按 record_id) +steps: + - id: del_dns + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_API_TOKEN=${env_cf_api_token} INPUT_RECORD_ID=${env.INPUT_RECORD_ID} python3 - <<'PY'\nimport os,requests,json\nrec=os.getenv('INPUT_RECORD_ID','')\ntoken=os.getenv('CF_API_TOKEN','')\nemail=os.getenv('CF_API_EMAIL','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\n# find record across zones\nzones=requests.get('https://api.cloudflare.com/client/v4/zones?per_page=200', headers=headers, timeout=15).json().get('result',[])\nzone_id=None\nfor z in zones:\n zid=z.get('id')\n r=requests.get(f'https://api.cloudflare.com/client/v4/zones/{zid}/dns_records/{rec}', headers=headers, timeout=15)\n if r.status_code==200 and r.json().get('success'):\n zone_id=zid\n break\nif not zone_id:\n print(json.dumps({'success':False,'errors':['record_not_found']}))\n raise SystemExit(1)\nresp=requests.delete(f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec}', headers=headers, timeout=15)\nprint(resp.text)\nPY" + - id: assert + action: assert.json + on_fail: stop + with: + source_step: del_dns + expect_success: true + required_paths: + - "success" diff --git a/runbooks/cf_dns_list.yaml b/runbooks/cf_dns_list.yaml new file mode 100644 index 0000000..2616ba2 --- /dev/null +++ b/runbooks/cf_dns_list.yaml @@ -0,0 +1,18 @@ +version: 1 +name: cf_dns_list +description: 列出某个 Zone 的 DNS 记录 +steps: + - id: list_dns + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_ACCOUNT_ID=${env_cf_account_id} CF_API_TOKEN=${env_cf_api_token} INPUT_ZONE_ID=${env.INPUT_ZONE_ID} python3 -c \"import os,requests; zone=os.getenv('INPUT_ZONE_ID',''); token=os.getenv('CF_API_TOKEN',''); email=os.getenv('CF_API_EMAIL',''); headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'}; url='https://api.cloudflare.com/client/v4/zones/%s/dns_records'%zone; resp=requests.get(url, headers=headers, timeout=15); print(resp.text)\"" + - id: assert + action: assert.json + on_fail: stop + with: + source_step: list_dns + expect_success: true + required_paths: + - "success" diff --git a/runbooks/cf_dns_proxy.yaml b/runbooks/cf_dns_proxy.yaml index 61e423f..dea435f 100644 --- a/runbooks/cf_dns_proxy.yaml +++ b/runbooks/cf_dns_proxy.yaml @@ -13,6 +13,7 @@ steps: on_fail: stop with: source_step: resolve_dns + expect_success: true required_paths: - "success" - id: update_dns @@ -26,5 +27,6 @@ steps: on_fail: stop with: source_step: update_dns + expect_success: true required_paths: - "success" diff --git a/runbooks/cf_dns_set.yaml b/runbooks/cf_dns_set.yaml new file mode 100644 index 0000000..b37d9de --- /dev/null +++ b/runbooks/cf_dns_set.yaml @@ -0,0 +1,32 @@ +version: 1 +name: cf_dns_set +description: 修改 DNS 记录内容(按 record_id) +steps: + - id: get_dns + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_API_TOKEN=${env_cf_api_token} INPUT_RECORD_ID=${env.INPUT_RECORD_ID} python3 - <<'PY'\nimport os,requests\nrec=os.getenv('INPUT_RECORD_ID','')\ntoken=os.getenv('CF_API_TOKEN','')\nemail=os.getenv('CF_API_EMAIL','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\nurl=f'https://api.cloudflare.com/client/v4/zones?per_page=200'\nresp=requests.get(url, headers=headers, timeout=15)\nresp.raise_for_status()\n# find record across zones\nfor z in resp.json().get('result',[]):\n zone_id=z.get('id')\n rurl=f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec}'\n r=requests.get(rurl, headers=headers, timeout=15)\n if r.status_code==200:\n print(r.text)\n raise SystemExit(0)\nprint('{"success":false,"errors":["record_not_found"]}')\nPY" + - id: assert_get + action: assert.json + on_fail: stop + with: + source_step: get_dns + expect_success: true + required_paths: + - "success" + - id: update_dns + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_API_TOKEN=${env_cf_api_token} INPUT_RECORD_ID=${env.INPUT_RECORD_ID} INPUT_CONTENT=${env.INPUT_CONTENT} INPUT_PROXIED=${env.INPUT_PROXIED} python3 - <<'PY'\nimport os,requests,json\nrec=os.getenv('INPUT_RECORD_ID','')\ncontent=os.getenv('INPUT_CONTENT','')\nproxied=os.getenv('INPUT_PROXIED','false').lower()=='true'\ntoken=os.getenv('CF_API_TOKEN','')\nemail=os.getenv('CF_API_EMAIL','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\n# find record and zone\nzones=requests.get('https://api.cloudflare.com/client/v4/zones?per_page=200', headers=headers, timeout=15).json().get('result',[])\nzone_id=None\nrecord=None\nfor z in zones:\n zid=z.get('id')\n r=requests.get(f'https://api.cloudflare.com/client/v4/zones/{zid}/dns_records/{rec}', headers=headers, timeout=15)\n if r.status_code==200:\n data=r.json()\n if data.get('success'):\n zone_id=zid\n record=data.get('result')\n break\nif not zone_id or not record:\n print(json.dumps({'success':False,'errors':['record_not_found']}))\n raise SystemExit(1)\npayload={\n 'type': record.get('type'),\n 'name': record.get('name'),\n 'content': content,\n 'proxied': proxied\n}\nresp=requests.put(f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec}', headers=headers, json=payload, timeout=15)\nprint(resp.text)\nPY" + - id: assert_update + action: assert.json + on_fail: stop + with: + source_step: update_dns + expect_success: true + required_paths: + - "success" diff --git a/runbooks/cf_dns_update.yaml b/runbooks/cf_dns_update.yaml new file mode 100644 index 0000000..cc738f1 --- /dev/null +++ b/runbooks/cf_dns_update.yaml @@ -0,0 +1,18 @@ +version: 1 +name: cf_dns_update +description: 更新 DNS 记录(按 record_id) +steps: + - id: update_dns + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_ACCOUNT_ID=${env_cf_account_id} CF_API_TOKEN=${env_cf_api_token} INPUT_ZONE_ID=${env.INPUT_ZONE_ID} INPUT_RECORD_ID=${env.INPUT_RECORD_ID} INPUT_TYPE=${env.INPUT_TYPE} INPUT_NAME=${env.INPUT_NAME} INPUT_CONTENT=${env.INPUT_CONTENT} INPUT_TTL=${env.INPUT_TTL} INPUT_PROXIED=${env.INPUT_PROXIED} python3 - <<'PY'\nimport os, requests\nzone=os.getenv('INPUT_ZONE_ID','')\nrec=os.getenv('INPUT_RECORD_ID','')\ntoken=os.getenv('CF_API_TOKEN','')\nemail=os.getenv('CF_API_EMAIL','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\nurl=f'https://api.cloudflare.com/client/v4/zones/{zone}/dns_records/{rec}'\npayload={\n 'type': os.getenv('INPUT_TYPE',''),\n 'name': os.getenv('INPUT_NAME',''),\n 'content': os.getenv('INPUT_CONTENT',''),\n}\nif os.getenv('INPUT_TTL',''):\n payload['ttl']=int(os.getenv('INPUT_TTL'))\nif os.getenv('INPUT_PROXIED','')!='':\n payload['proxied']=os.getenv('INPUT_PROXIED').lower()=='true'\nresp=requests.put(url, headers=headers, json=payload, timeout=15)\nprint(resp.text)\nPY" + - id: assert + action: assert.json + on_fail: stop + with: + source_step: update_dns + expect_success: true + required_paths: + - "success" diff --git a/runbooks/cf_status.yaml b/runbooks/cf_status.yaml new file mode 100644 index 0000000..1c49ac6 --- /dev/null +++ b/runbooks/cf_status.yaml @@ -0,0 +1,10 @@ +version: 1 +name: cf_status +description: CF 模块状态检查占位 runbook(不执行外部操作) +inputs: [] +steps: + - id: cf_noop + action: sleep + on_fail: stop + with: + ms: 10 diff --git a/runbooks/cf_workers_list.yaml b/runbooks/cf_workers_list.yaml new file mode 100644 index 0000000..2cfd48c --- /dev/null +++ b/runbooks/cf_workers_list.yaml @@ -0,0 +1,18 @@ +version: 1 +name: cf_workers_list +description: 列出账户下 Workers 脚本 +steps: + - id: list_workers + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_ACCOUNT_ID=${env_cf_account_id} CF_API_TOKEN=${env_cf_api_token} python3 -c \"import os,requests,json; acct=os.getenv('CF_ACCOUNT_ID',''); token=os.getenv('CF_API_TOKEN',''); headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'}; url='https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts'%acct; resp=requests.get(url, headers=headers, timeout=15); data=resp.json(); workers=[(w.get('id') or w.get('name')) for w in data.get('result',[])]; print(json.dumps({'workers':workers}, ensure_ascii=False))\"" + - id: assert + action: assert.json + on_fail: stop + with: + source_step: list_workers + expect_success: true + required_paths: + - "success" diff --git a/runbooks/cf_zones.yaml b/runbooks/cf_zones.yaml new file mode 100644 index 0000000..b2b4bc6 --- /dev/null +++ b/runbooks/cf_zones.yaml @@ -0,0 +1,18 @@ +version: 1 +name: cf_zones +description: 列出 Cloudflare 账号下的 Zone +steps: + - id: list_zones + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_ACCOUNT_ID=${env_cf_account_id} CF_API_TOKEN=${env_cf_api_token} python3 -c \"import os,requests,json; acct=os.getenv('CF_ACCOUNT_ID',''); token=os.getenv('CF_API_TOKEN',''); email=os.getenv('CF_API_EMAIL',''); headers={'Authorization':'Bearer '+token,'Content-Type':'application/json'}; url='https://api.cloudflare.com/client/v4/zones'; params={'account.id':acct,'per_page':200}; resp=requests.get(url, headers=headers, params=params, timeout=15); data=resp.json(); zones=[{'name':z.get('name'), 'id':z.get('id')} for z in data.get('result',[])]; print(json.dumps({'success':data.get('success',False),'zones':zones,'errors':data.get('errors',[])}, ensure_ascii=False))\"" + - id: assert + action: assert.json + on_fail: stop + with: + source_step: list_zones + expect_success: true + required_paths: + - "success" diff --git a/runbooks/cpa_status.yaml b/runbooks/cpa_status.yaml new file mode 100644 index 0000000..274bfaa --- /dev/null +++ b/runbooks/cpa_status.yaml @@ -0,0 +1,20 @@ +version: 1 +name: cpa_status +description: 获取 CPA 服务状态与 usage 快照 +inputs: [] +steps: + - id: usage_snapshot + action: shell.exec + on_fail: stop + with: + command: "CPA_TOKEN=${env.cpa_management_token} CPA_BASE=https://cpa.pao.xx.kg/v0/management python3 -c 'import base64,os; code=base64.b64decode(\"Y3VybCAtc1MgLUggIkF1dGhvcml6YXRpb246IEJlYXJlciAke0NQQV9UT0tFTn0iICR7Q1BBX0JBU0V9L3VzYWdlIHwgcHl0aG9uMyAtYyAiaW1wb3J0IGpzb24sc3lzOyBkYXRhPWpzb24ubG9hZChzeXMuc3RkaW4pOyBvdXQ9eyd1c2FnZSc6IHsndG90YWxfcmVxdWVzdHMnOiBkYXRhLmdldCgndXNhZ2UnLHt9KS5nZXQoJ3RvdGFsX3JlcXVlc3RzJyksICd0b3RhbF90b2tlbnMnOiBkYXRhLmdldCgndXNhZ2UnLHt9KS5nZXQoJ3RvdGFsX3Rva2VucycpfX07IHByaW50KGpzb24uZHVtcHMob3V0LCBlbnN1cmVfYXNjaWk9RmFsc2UpKSIK\"); os.system(code.decode())'" + + - id: usage_assert + action: assert.json + on_fail: stop + with: + source_step: usage_snapshot + expect_success: true + required_paths: + - "usage.total_requests" + - "usage.total_tokens" diff --git a/runbooks/cpa_usage_restore.yaml b/runbooks/cpa_usage_restore.yaml index 0f91478..a2edde5 100644 --- a/runbooks/cpa_usage_restore.yaml +++ b/runbooks/cpa_usage_restore.yaml @@ -83,6 +83,7 @@ steps: on_fail: stop with: source_step: verify_now + expect_success: true required_paths: - "usage.total_requests" - "usage.total_tokens" @@ -107,6 +108,7 @@ steps: on_fail: stop with: source_step: verify_later + expect_success: true required_paths: - "usage.total_requests" - "usage.total_tokens" diff --git a/runbooks/mail_status.yaml b/runbooks/mail_status.yaml new file mode 100644 index 0000000..f2148f8 --- /dev/null +++ b/runbooks/mail_status.yaml @@ -0,0 +1,10 @@ +version: 1 +name: mail_status +description: Mail 模块状态检查占位 runbook(不执行外部操作) +inputs: [] +steps: + - id: mail_noop + action: sleep + on_fail: stop + with: + ms: 10 diff --git a/templates/ai_settings.html b/templates/ai_settings.html new file mode 100644 index 0000000..312d8ea --- /dev/null +++ b/templates/ai_settings.html @@ -0,0 +1,145 @@ + + + + + +AI 配置 - Ops-Assistant + + + + +
+
+

AI 模型配置

+
+
+
+
+
+ 用于将“非命令文本”翻译为标准命令(仅翻译,不自动执行)。 +
+ + +
+ +
+
+ + + diff --git a/templates/audit.html b/templates/audit.html index 7732b20..1716b4a 100644 --- a/templates/audit.html +++ b/templates/audit.html @@ -3,37 +3,60 @@ -🧾 审计日志 - 虾记 +🧾 审计日志 - Ops-Assistant @@ -50,8 +74,21 @@ button.secondary:hover{background:#4b5563}
{{.version}}
+ 返回首页 - 渠道配置 + 渠道配置 退出
@@ -73,8 +110,11 @@ button.secondary:hover{background:#4b5563} diff --git a/templates/cf_settings.html b/templates/cf_settings.html new file mode 100644 index 0000000..d775082 --- /dev/null +++ b/templates/cf_settings.html @@ -0,0 +1,80 @@ + + + + + +Cloudflare 配置 - Ops-Assistant + + + +
+
☁️ Cloudflare 配置
+ +
+
+
+

账号凭据

+
+
+ 用于查询/修改 DNS & Workers(单账号) +
+ + +
+ +
+
+ + + diff --git a/templates/cfdav_settings.html b/templates/cfdav_settings.html new file mode 100644 index 0000000..cc0abc5 --- /dev/null +++ b/templates/cfdav_settings.html @@ -0,0 +1,157 @@ + + + + + +cfdav 配置 - Ops-Assistant + + + +
+
🗂️ cfdav 管理
+ +
+
+
+

连接配置

+
+
+
+
+ + +
+ +
+ +
+

创建用户

+
+
+
+
+ + +
+ +
+ +
+

用户列表

+ + + +
ID账号管理员创建时间操作
+
+
+ + + + diff --git a/templates/channels.html b/templates/channels.html index 719bd03..635aa1d 100644 --- a/templates/channels.html +++ b/templates/channels.html @@ -3,34 +3,60 @@ -渠道配置 - 虾记 +渠道配置 - Ops-Assistant
🦞 渠道配置中心(草稿/发布) · {{.version}}
- +
+ + 返回首页 + 退出 +
- - + +
-
默认推荐:直接点“保存并立即生效”。高级场景再用“保存草稿 / 发布草稿 / 热加载”。
+
填写需要的参数即可(每项一个输入框)。高级 JSON 已折叠,默认不需要碰。
diff --git a/templates/cpa_settings.html b/templates/cpa_settings.html new file mode 100644 index 0000000..233ca34 --- /dev/null +++ b/templates/cpa_settings.html @@ -0,0 +1,207 @@ + + + + + +CPA 配置 - Ops-Assistant + + + +
+
🔧 CPA 配置 · {{.version}}
+
+ + 返回首页 + 退出 +
+
+
+
+

CPA 管理接口

+
+
+ 用于访问 CPA 管理接口(Bearer Token) +
+ + +
+ +
+ +
+

目标主机(Ops Targets)

+
+
+
+
+
+
+ +
+ + + +
NameHostPortUserEnabled操作
+
+
+
+ + + diff --git a/templates/index.html b/templates/index.html index 1de064e..a43ff46 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,234 +3,256 @@ -🦞 虾记记账 +🛠️ Ops-Assistant
-

🦞 虾记记账

-
{{.version}}
- 退出 +
+

🛠️ Ops-Assistant

+
{{.version}}
+
+
+ + 退出 +
-
-
0.00
今日支出
-
0.00
本月支出
-
0
总记录数
-
- -
- - -
- - - - 📥 导出CSV -
- -
-
高级功能开关(高风险默认关闭)
-
-
- -
- -