feat: 模块化命令框架 + runbook 严格断言与查询结果回传
- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter - runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传 - cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败 - 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success - 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度 - Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 <db_path> <base_dir> <command_text>")
|
||||
os.Exit(2)
|
||||
}
|
||||
dbPath := os.Args[1]
|
||||
baseDir := os.Args[2]
|
||||
cmd := os.Args[3]
|
||||
inputs := map[string]string{}
|
||||
// minimal parse for /cf dnsadd <name> <content> [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
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -1 +0,0 @@
|
||||
55bfe12944a42957532b9f63492d9ed8ca600419c4352ffa35344a62598bc019 dist/ops-assistant-v0.0.1-linux-amd64
|
||||
@@ -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
|
||||
@@ -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 <backup_id>
|
||||
**用途**:从备份恢复 usage。
|
||||
**常见自然语**:
|
||||
- 恢复 cpa / 回滚 cpa
|
||||
- 用备份 <id> 恢复
|
||||
- 恢复 usage <id>
|
||||
**输出命令**:
|
||||
- `/cpa usage restore <backup_id>`
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare 模块
|
||||
### /cf status
|
||||
**用途**:查看 Cloudflare 配置状态。
|
||||
**常见自然语**:
|
||||
- cf 状态 / cloudflare 状态
|
||||
- cf 配置怎么样
|
||||
**输出命令**:
|
||||
- `/cf status`
|
||||
|
||||
### /cf zones
|
||||
**用途**:列出 zone 列表。
|
||||
**常见自然语**:
|
||||
- cf zones / cf 区域列表 / 站点列表
|
||||
**输出命令**:
|
||||
- `/cf zones`
|
||||
|
||||
### /cf dns list <zone_id>
|
||||
**用途**:列出指定 zone 的 DNS 记录。
|
||||
**常见自然语**:
|
||||
- 列出 DNS / 查看 DNS / cf dns list
|
||||
- zone <id> 的 dns 记录
|
||||
**输出命令**:
|
||||
- `/cf dns list <zone_id>`
|
||||
|
||||
### /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]
|
||||
**用途**:更新 DNS 记录。
|
||||
**常见自然语**:
|
||||
- 更新 dns / 修改解析
|
||||
- 把 <name> 改成 <ip>
|
||||
**输出命令**:
|
||||
- `/cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]`
|
||||
|
||||
### /cf dnsadd <name> <content> [true] [type]
|
||||
**用途**:新增 DNS 记录(name 自动匹配 zone)。
|
||||
**常见自然语**:
|
||||
- 新增解析 / 添加 DNS
|
||||
- 增加 <name> -> <ip>
|
||||
**输出命令**:
|
||||
- `/cf dnsadd <name> <content> [true] [type]`
|
||||
|
||||
### /cf dnsset <record_id> <content> [true]
|
||||
**用途**:按 record_id 修改内容(可选开启代理)。
|
||||
**常见自然语**:
|
||||
- 更新记录内容 / 改 IP
|
||||
**输出命令**:
|
||||
- `/cf dnsset <record_id> <content> [true]`
|
||||
|
||||
### /cf dnsdel <record_id> YES
|
||||
**用途**:删除 DNS 记录(需 YES 确认)。
|
||||
**常见自然语**:
|
||||
- 删除 DNS 记录 / 删解析
|
||||
**输出命令**:
|
||||
- `/cf dnsdel <record_id> YES`
|
||||
|
||||
### /cf dnsproxy <record_id> on|off
|
||||
**用途**:切换代理开关(不改 content)。
|
||||
**常见自然语**:
|
||||
- 开启/关闭代理
|
||||
**输出命令**:
|
||||
- `/cf dnsproxy <record_id> 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 等关键参数。
|
||||
@@ -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 管理,不改代码路径
|
||||
@@ -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` 并行。
|
||||
@@ -0,0 +1,123 @@
|
||||
# CF 命令设计(ops-assistant v1)
|
||||
|
||||
## 目标
|
||||
为 ops-assistant 提供 Cloudflare DNS 管理能力,保证幂等、安全、可审计。
|
||||
|
||||
---
|
||||
|
||||
## 1. 命令清单
|
||||
|
||||
- `/cf zones`
|
||||
- 列出可用 Zone(name/id)
|
||||
|
||||
- `/cf dnslist <zone> [name] [type]`
|
||||
- 示例:`/cf dnslist example.com`
|
||||
- 示例:`/cf dnslist example.com www A`
|
||||
|
||||
- `/cf dnsadd <fqdn> <ip> [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]`
|
||||
- 示例:`/cf dnsadd test.fnos.xx.kg 1.2.3.4`
|
||||
|
||||
- `/cf dnsset <fqdn> <ip> [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]`
|
||||
- 存在则更新,不存在则创建
|
||||
|
||||
- `/cf dnsdel <fqdn> --type <A/AAAA/CNAME>`
|
||||
- **必须带 `--type`**
|
||||
- **需二次确认 `YES`**
|
||||
|
||||
- `/cf dnsproxy <fqdn> on|off`
|
||||
- 仅更新 proxied
|
||||
|
||||
- `/cf health`
|
||||
- Token/权限可用性检查
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限与安全
|
||||
|
||||
- `read` 角色:仅允许 `zones / dnslist / health`
|
||||
- `admin` 角色:允许所有命令
|
||||
- `dnsdel` 必须二次确认 `YES`
|
||||
|
||||
---
|
||||
|
||||
## 3. 参数校验
|
||||
|
||||
- `fqdn` 必须属于某个 Zone(`<sub>.<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: <fqdn>
|
||||
- type: <type>
|
||||
- content: <ip>
|
||||
- ttl: <ttl>
|
||||
- proxied: <true/false>
|
||||
- id: <record_id>
|
||||
```
|
||||
|
||||
### 已存在
|
||||
```
|
||||
ℹ️ DNS记录已存在(未变更)
|
||||
- name: <fqdn>
|
||||
- type: <type>
|
||||
- content: <ip>
|
||||
- id: <record_id>
|
||||
```
|
||||
|
||||
### 失败
|
||||
```
|
||||
❌ DNS操作失败
|
||||
- reason: <鉴权/参数/CF错误>
|
||||
- detail: <error_message>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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}
|
||||
}
|
||||
```
|
||||
@@ -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 远端执行。
|
||||
@@ -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
|
||||
- [ ] 错误信息对用户可读
|
||||
@@ -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 可忽略(非功能错误)。
|
||||
@@ -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 下线窗口
|
||||
@@ -0,0 +1,37 @@
|
||||
# Module Adapter Template
|
||||
|
||||
目标:新功能模块以最小改动接入 `ops-assistant`。
|
||||
|
||||
## 必备文件
|
||||
|
||||
- `internal/module/<name>/module.go`
|
||||
- `runbooks/<name>_*.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 可审计
|
||||
@@ -1,4 +1,4 @@
|
||||
# Xiaji-Go 多平台渠道配置与回调部署说明
|
||||
# Ops-Assistant 多平台渠道配置与回调部署说明
|
||||
|
||||
## 已支持平台
|
||||
- 官方 QQ Bot(qqbot_official)
|
||||
|
||||
@@ -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 <backup_id>`
|
||||
|
||||
### 约束
|
||||
- 仅允许上述命令(严格白名单)
|
||||
- 每个命令映射到唯一 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.<id>.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 <id>` 支持双校验(立即 + 延迟)
|
||||
4. 任一步骤失败时可追溯到具体 step 日志
|
||||
5. 未授权命令必须被拒绝并记录审计
|
||||
|
||||
---
|
||||
|
||||
## 当前已落地(2026-03-10)
|
||||
|
||||
1. 已打通命令入口:Telegram / QQ / 飞书
|
||||
2. 已支持命令:
|
||||
- `/cpa status`
|
||||
- `/cpa usage backup`
|
||||
- `/cpa usage restore <backup_id>`
|
||||
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 验收完成后)
|
||||
@@ -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 - 欢迎信息"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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 <zone_id> 这种输入参数写入 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 <zone_id>
|
||||
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 <zone_id> <record_id> <type> <name> <content> [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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 <backup_id> [--confirm YES_RESTORE] [--dry-run]",
|
||||
"- /cf status (需要 enable_module_cf)",
|
||||
"- /cf zones (需要 enable_module_cf)",
|
||||
"- /cf dns list <zone_id> (需要 enable_module_cf)",
|
||||
"- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false] (需要 enable_module_cf)",
|
||||
"- /cf dnsadd <name> <content> [on|off] [type] (需要 enable_module_cf)",
|
||||
"- /cf dnsset <record_id> <content> [true] (需要 enable_module_cf)",
|
||||
"- /cf dnsdel <record_id> YES (需要 enable_module_cf)",
|
||||
"- /cf dnsproxy <record_id|name> 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")
|
||||
}
|
||||
|
||||
@@ -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 <token>")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ports
|
||||
|
||||
type Module interface {
|
||||
Name() string
|
||||
CommandPrefix() string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <zone_id>"))
|
||||
}
|
||||
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 <zone_id>(未实际执行)",
|
||||
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 <zone_id>",
|
||||
@@ -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 执行失败: ",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <email> <password> [admin]\n- /cfdav del <user_id>", 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 <email> <pass> [admin]
|
||||
// 或: /cfdav user add <email> <pass> [admin]
|
||||
idx := 2
|
||||
if len(parts) >= 3 && parts[1] == "user" {
|
||||
idx = 3
|
||||
}
|
||||
if len(parts) < idx+2 {
|
||||
return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav add <email> <password> [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 <id> 或 /cfdav user del <id>
|
||||
idx := 2
|
||||
if len(parts) >= 3 && parts[1] == "user" {
|
||||
idx = 3
|
||||
}
|
||||
if len(parts) < idx+1 {
|
||||
return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav del <user_id>"), 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
|
||||
}
|
||||
@@ -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 <backup_id>")
|
||||
}
|
||||
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 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <backup_id> [--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 <backup_id>", nil
|
||||
}
|
||||
@@ -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 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <id>\n- /sub cancel(或 /cancel)取消新增流程", nil
|
||||
}
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) < 2 {
|
||||
return "❌ 用法:/sub add | /sub list | /sub del <id>", 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 <id>", nil
|
||||
}
|
||||
return m.del(userID, strings.TrimSpace(parts[2]))
|
||||
default:
|
||||
return "❌ 用法:/sub add | /sub list | /sub del <id>", 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+316
-75
@@ -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"
|
||||
)
|
||||
|
||||
@@ -27,16 +34,22 @@ type QQBot struct {
|
||||
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")
|
||||
// sub 引导式新增流程(优先级高于模块命令)
|
||||
if reply, handled := b.handleSubFlow(userID, text); handled {
|
||||
return reply
|
||||
}
|
||||
|
||||
switch {
|
||||
case isCommand(text, "帮助", "help", "/help", "/start", "菜单", "功能"):
|
||||
return "🦞 虾记记账\n\n" +
|
||||
"直接发送消费描述即可记账:\n" +
|
||||
"• 午饭 25元\n" +
|
||||
"• 打车 ¥30\n" +
|
||||
"• 买咖啡15块\n\n" +
|
||||
"📋 命令列表:\n" +
|
||||
"• 记录/查看 — 最近10条\n" +
|
||||
"• 今日/今天 — 今日汇总\n" +
|
||||
"• 统计/报表 — 本月分类统计\n" +
|
||||
"• 帮助 — 本帮助信息"
|
||||
// 先交给 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
|
||||
}
|
||||
}
|
||||
|
||||
case isCommand(text, "查看", "记录", "列表", "list", "/list", "最近"):
|
||||
items, err := b.finance.GetTransactions(DefaultUserID, 10)
|
||||
// 规则兜底:常见自然语映射到标准命令
|
||||
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 <zone_id>"
|
||||
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 {
|
||||
return "❌ 查询失败"
|
||||
log.Printf("QQ 推送任务结果失败: %v", err)
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
amount, category, err := b.finance.AddTransaction(DefaultUserID, text)
|
||||
if err != nil {
|
||||
log.Printf("QQ记账失败 user=%s: %v", userID, err)
|
||||
return "❌ 记账失败,请稍后重试"
|
||||
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 amount == 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
amountYuan := float64(amount) / 100.0
|
||||
return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text)
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <id>\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
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"xiaji-go/models"
|
||||
"ops-assistant/models"
|
||||
|
||||
"github.com/yanyiwu/gojieba"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
+275
-2
@@ -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") {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;
|
||||
--accent:#ee5a24;--accent-hover:#d63031;--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;
|
||||
--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
|
||||
.header button{width:28px;padding:0;transition:transform .2s ease}
|
||||
.header button.spin{transform:rotate(180deg)}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
small{color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🤖 AI 配置 · {{.version}}</div>
|
||||
<div class="right">
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>AI 模型配置</h3>
|
||||
<div class="row"><label>启用 AI 翻译</label><input id="enabled" type="checkbox"></div>
|
||||
<div class="row"><label>Base URL</label><input id="base_url" placeholder="https://api.xxx/v1"></div>
|
||||
<div class="row"><label>API Key</label><input id="api_key" type="password" placeholder="sk-..." autocomplete="new-password"></div>
|
||||
<div class="row"><label>Model</label><input id="model" placeholder="gemini-3-flash-preview"></div>
|
||||
<div class="row"><label>Timeout (秒)</label><input id="timeout" placeholder="15"></div>
|
||||
<small>用于将“非命令文本”翻译为标准命令(仅翻译,不自动执行)。</small>
|
||||
<div class="btns">
|
||||
<button onclick="save()">保存</button>
|
||||
<button class="secondary" onclick="load()">刷新</button>
|
||||
</div>
|
||||
<small id="msg"></small>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/ai/settings');
|
||||
const s=d.settings||{};
|
||||
document.getElementById('enabled').checked = (String(s.ai_enabled||'').toLowerCase()==='true');
|
||||
document.getElementById('base_url').value = s.ai_base_url || '';
|
||||
document.getElementById('api_key').value = s.ai_api_key || '';
|
||||
document.getElementById('model').value = s.ai_model || '';
|
||||
document.getElementById('timeout').value = s.ai_timeout_seconds || '15';
|
||||
document.getElementById('msg').textContent='已加载';
|
||||
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(){
|
||||
const payload={
|
||||
enabled: document.getElementById('enabled').checked,
|
||||
base_url: document.getElementById('base_url').value.trim(),
|
||||
api_key: document.getElementById('api_key').value.trim(),
|
||||
model: document.getElementById('model').value.trim(),
|
||||
timeout_seconds: parseInt(document.getElementById('timeout').value||'15',10)
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/ai/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('msg').textContent='已保存';
|
||||
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
|
||||
}
|
||||
function setThemeIcon(theme){
|
||||
const icon=document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
if(theme==='dark'){
|
||||
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
||||
}else{
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle>'+
|
||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
||||
}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
||||
else{document.documentElement.removeAttribute('data-theme');}
|
||||
localStorage.setItem('theme',theme);
|
||||
setThemeIcon(theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
const next = cur==='light'?'dark':'light';
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
||||
applyTheme(next);
|
||||
}
|
||||
(function(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.addEventListener('click',toggleTheme)}
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+96
-20
@@ -3,37 +3,60 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🧾 审计日志 - 虾记</title>
|
||||
<title>🧾 审计日志 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333;min-height:100vh}
|
||||
.header{background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;padding:20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .title{font-weight:700}
|
||||
.header .sub{font-size:12px;opacity:.9}
|
||||
.header a{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer}
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.header a:hover{background:rgba(255,255,255,.35)}
|
||||
|
||||
.wrap{max-width:600px;margin:0 auto;padding:15px}
|
||||
.filters{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05);margin-bottom:10px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
||||
.filters input,.filters select{width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:#fff}
|
||||
small{color:#6b7280}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:14px}
|
||||
.filters{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
||||
.filters input,.filters select{width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
small{color:var(--muted)}
|
||||
|
||||
.actions{margin:0 0 10px;display:flex;gap:8px;flex-wrap:wrap}
|
||||
button{border:none;border-radius:8px;padding:8px 12px;cursor:pointer;color:#fff;background:#ee5a24;font-size:13px}
|
||||
button:hover{background:#d63031}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button:hover{background:var(--accent-hover)}
|
||||
button.secondary{background:#6b7280}
|
||||
button.secondary:hover{background:#4b5563}
|
||||
|
||||
.list{display:flex;flex-direction:column;gap:10px}
|
||||
.log-card{background:#fff;border-radius:12px;padding:12px;box-shadow:0 1px 4px rgba(0,0,0,.05)}
|
||||
.log-card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none}
|
||||
.row{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:6px;font-size:12px}
|
||||
.tag.success{background:#dcfce7;color:#166534}
|
||||
.tag.failed{background:#fee2e2;color:#991b1b}
|
||||
.tag.denied{background:#fef3c7;color:#92400e}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#4b5563;word-break:break-all}
|
||||
.note{font-size:13px;color:#374151;margin-top:6px;white-space:pre-wrap;word-break:break-word}
|
||||
.empty{text-align:center;padding:40px 10px;color:#999}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
|
||||
.note{font-size:13px;color:var(--text);margin-top:6px;white-space:pre-wrap;word-break:break-word}
|
||||
.empty{text-align:center;padding:40px 10px;color:var(--muted)}
|
||||
.hidden{display:none !important;}
|
||||
|
||||
@media(max-width:640px){
|
||||
.header{padding:14px 12px 10px;align-items:flex-start;flex-direction:column}
|
||||
@@ -41,6 +64,7 @@ button.secondary:hover{background:#4b5563}
|
||||
.wrap{padding:12px}
|
||||
.filters{grid-template-columns:1fr}
|
||||
}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -50,8 +74,21 @@ button.secondary:hover{background:#4b5563}
|
||||
<div class="sub">{{.version}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/channels">渠道配置</a>
|
||||
<a id="btnChannels" href="/channels">渠道配置</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,8 +110,11 @@ button.secondary:hover{background:#4b5563}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let me=null;
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function qs(id){return document.getElementById(id).value.trim();}
|
||||
async function api(url,opt={}){const r=await fetch(url,opt);const out=await r.json().catch(()=>({}));if(!r.ok) throw new Error(out.message||('HTTP '+r.status));return out?.data||{};}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
|
||||
const actionMap={
|
||||
'auth.login.success':'登录成功','auth.login.failed':'登录失败','auth.logout':'退出登录',
|
||||
@@ -111,9 +151,9 @@ async function loadAudit(){
|
||||
const listEl=document.getElementById('list');
|
||||
listEl.innerHTML='<div class="empty">加载中...</div>';
|
||||
|
||||
const r=await fetch('/api/v1/admin/audit?'+p.toString());
|
||||
const data=await r.json().catch(()=>[]);
|
||||
const list=Array.isArray(data)?data:[];
|
||||
try{
|
||||
const out=await api('/api/v1/admin/audit?'+p.toString());
|
||||
const list=Array.isArray(out.logs)?out.logs:[];
|
||||
if(!list.length){listEl.innerHTML='<div class="empty">暂无审计记录</div>';return;}
|
||||
|
||||
listEl.innerHTML=list.map(it=>{
|
||||
@@ -127,9 +167,45 @@ async function loadAudit(){
|
||||
<div class="note">${esc(note||'(无备注)')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}catch(e){
|
||||
listEl.innerHTML='<div class="empty">加载失败:'+esc(e.message||e)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
loadAudit();
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
function initPermissionUI(){
|
||||
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
|
||||
}
|
||||
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
(async function(){
|
||||
try{ initTheme(); await loadMe(); initPermissionUI(); await loadAudit(); }
|
||||
catch(e){ document.getElementById('list').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>'; }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cloudflare 配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;
|
||||
--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button{width:28px;padding:0}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:#0b1220;color:var(--text)}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
small{color:var(--muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>☁️ Cloudflare 配置</div>
|
||||
<div class="right">
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>账号凭据</h3>
|
||||
<div class="row"><label>Account ID</label><input id="account" placeholder="请输入 Account ID"></div>
|
||||
<div class="row"><label>API Token</label><input id="token" placeholder="请输入 API Token"></div>
|
||||
<small>用于查询/修改 DNS & Workers(单账号)</small>
|
||||
<div class="btns">
|
||||
<button onclick="save()">保存</button>
|
||||
<button class="secondary" onclick="load()">刷新</button>
|
||||
</div>
|
||||
<small id="msg"></small>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/cf/settings');
|
||||
document.getElementById('account').value = d.settings?.cf_account_id || '';
|
||||
document.getElementById('token').value = d.settings?.cf_api_token || '';
|
||||
document.getElementById('msg').textContent='已加载';
|
||||
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(){
|
||||
const payload={
|
||||
account_id: document.getElementById('account').value.trim(),
|
||||
api_token: document.getElementById('token').value.trim()
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/cf/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('msg').textContent='已保存';
|
||||
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>cfdav 配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;line-height:28px;height:28px;display:inline-flex;align-items:center}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:#0b1220;color:var(--text)}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
button.danger{background:#9b1c1c}
|
||||
small{color:var(--muted)}
|
||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🗂️ cfdav 管理</div>
|
||||
<div class="right">
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>连接配置</h3>
|
||||
<div class="row"><label>API Base</label><input id="apiBase" placeholder="例如 https://cfdav.ouai.nyc.mn"></div>
|
||||
<div class="row"><label>管理员账号</label><input id="adminUser" placeholder="例如 kyy"></div>
|
||||
<div class="row"><label>管理员密码</label><input id="adminPass" placeholder="请输入密码"></div>
|
||||
<div class="btns">
|
||||
<button onclick="saveSettings()">保存</button>
|
||||
<button class="secondary" onclick="loadSettings()">刷新</button>
|
||||
</div>
|
||||
<small id="settingsMsg"></small>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>创建用户</h3>
|
||||
<div class="row"><label>邮箱/用户名</label><input id="newEmail" placeholder="例如 user01"></div>
|
||||
<div class="row"><label>密码</label><input id="newPass" placeholder="请输入密码"></div>
|
||||
<div class="row"><label>管理员</label><input id="newAdmin" type="checkbox"></div>
|
||||
<div class="btns">
|
||||
<button onclick="createUser()">新增用户</button>
|
||||
<button class="secondary" onclick="loadUsers()">刷新列表</button>
|
||||
</div>
|
||||
<small id="userMsg"></small>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>用户列表</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>ID</th><th>账号</th><th>管理员</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="usersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
|
||||
async function loadSettings(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/cfdav/settings');
|
||||
const s=d.settings||{};
|
||||
document.getElementById('apiBase').value=s.cfdav_api_base||'';
|
||||
document.getElementById('adminUser').value=s.cfdav_admin_user||'';
|
||||
document.getElementById('adminPass').value=s.cfdav_admin_pass||'';
|
||||
document.getElementById('settingsMsg').textContent='已加载';
|
||||
}catch(e){document.getElementById('settingsMsg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
|
||||
async function saveSettings(){
|
||||
const payload={
|
||||
api_base:document.getElementById('apiBase').value.trim(),
|
||||
admin_user:document.getElementById('adminUser').value.trim(),
|
||||
admin_pass:document.getElementById('adminPass').value.trim()
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/cfdav/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('settingsMsg').textContent='已保存';
|
||||
}catch(e){document.getElementById('settingsMsg').textContent='保存失败:'+esc(e.message||e);}
|
||||
}
|
||||
|
||||
function normalizeUsers(data){
|
||||
if(Array.isArray(data)) return data;
|
||||
if(Array.isArray(data?.data)) return data.data;
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const tbody=document.getElementById('usersBody');
|
||||
try{
|
||||
const d=await api('/api/v1/admin/cfdav/users');
|
||||
const list=normalizeUsers(d);
|
||||
if(!list.length){tbody.innerHTML='<tr><td colspan="5"><small>暂无用户</small></td></tr>';return;}
|
||||
tbody.innerHTML=list.map(u=>`<tr>
|
||||
<td>${esc(u.id)}</td>
|
||||
<td>${esc(u.email)}</td>
|
||||
<td>${u.is_admin===1?'<span class="badge on">YES</span>':'<span class="badge off">NO</span>'}</td>
|
||||
<td>${esc(u.created_at)}</td>
|
||||
<td><button class="danger" onclick="deleteUser('${encodeURIComponent(u.id)}')">删除</button></td>
|
||||
</tr>`).join('');
|
||||
}catch(e){
|
||||
tbody.innerHTML='<tr><td colspan="5"><small>加载失败:'+esc(e.message||e)+'</small></td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const payload={
|
||||
email:document.getElementById('newEmail').value.trim(),
|
||||
password:document.getElementById('newPass').value,
|
||||
isAdmin:document.getElementById('newAdmin').checked
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/cfdav/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('userMsg').textContent='创建成功';
|
||||
document.getElementById('newPass').value='';
|
||||
await loadUsers();
|
||||
}catch(e){document.getElementById('userMsg').textContent='创建失败:'+esc(e.message||e);}
|
||||
}
|
||||
|
||||
async function deleteUser(id){
|
||||
if(!confirm('确认删除该用户?')) return;
|
||||
try{
|
||||
await api('/api/v1/admin/cfdav/users/'+id,{method:'DELETE'});
|
||||
document.getElementById('userMsg').textContent='删除成功';
|
||||
await loadUsers();
|
||||
}catch(e){document.getElementById('userMsg').textContent='删除失败:'+esc(e.message||e);}
|
||||
}
|
||||
|
||||
(async function init(){
|
||||
await loadSettings();
|
||||
await loadUsers();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+196
-65
@@ -3,34 +3,60 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>渠道配置 - 虾记</title>
|
||||
<title>渠道配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f0f2f5; color: #333; min-height: 100vh; }
|
||||
.header { background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff; padding: 20px; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.header a { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
.header { background: var(--header-bg); color: #fff; padding: 18px 20px; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.header a,.header button { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; border:none; cursor:pointer; }
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.header a:hover { background: rgba(255,255,255,.35); }
|
||||
.wrap { max-width: 600px; margin: 0 auto; padding: 15px; }
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 14px; }
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
|
||||
.tip { color:#6b7280; font-size:12px; margin-bottom:10px; }
|
||||
.card { background:#fff; border-radius:12px; padding:14px; margin-bottom:10px; box-shadow:0 1px 4px rgba(0,0,0,.05); }
|
||||
.tip { color:var(--muted); font-size:12px; margin-bottom:10px; }
|
||||
.card { background:var(--card); border-radius:6px; padding:14px; margin-bottom:10px; border:1px solid var(--border); box-shadow:none; }
|
||||
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
|
||||
.row label { width:110px; color:#4b5563; font-size:13px; }
|
||||
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid #ddd; border-radius:8px; font-size:13px; }
|
||||
.row textarea { min-height:74px; }
|
||||
.row label { width:140px; color:var(--muted); font-size:13px; }
|
||||
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:13px; background:var(--card); color:var(--text); }
|
||||
.row textarea { min-height:74px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
|
||||
button { border:none; border-radius:8px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
|
||||
button { border:none; border-radius:6px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
|
||||
button:disabled { opacity:.65; cursor:not-allowed; }
|
||||
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:#ee5a24; }
|
||||
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:#d63031; }
|
||||
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:var(--accent); }
|
||||
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:var(--accent-hover); }
|
||||
.btn-disable { background:#9b2c2c; }
|
||||
.btn-disable:hover { background:#7f1d1d; }
|
||||
.badge { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; }
|
||||
.state { display:inline-block; font-size:12px; border-radius:999px; padding:2px 8px; margin-left:6px; }
|
||||
.btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
|
||||
.btn-ghost:hover { background:rgba(0,0,0,.04); }
|
||||
.badge { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; }
|
||||
.state { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; margin-left:6px; }
|
||||
.ok { background:#dcfce7; color:#166534; }
|
||||
.error { background:#fee2e2; color:#991b1b; }
|
||||
.disabled { background:#e5e7eb; color:#374151; }
|
||||
small { color:#6b7280; }
|
||||
small { color:var(--muted); }
|
||||
.hidden{display:none !important;}
|
||||
.advanced{border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;}
|
||||
|
||||
@media(max-width:640px){
|
||||
.header { padding: 14px 12px 10px; align-items:flex-start; flex-direction:column; }
|
||||
@@ -41,25 +67,52 @@ small { color:#6b7280; }
|
||||
.row input, .row textarea { min-width:100%; }
|
||||
.btns button { flex: 1 1 calc(50% - 4px); }
|
||||
}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🦞 渠道配置中心(草稿/发布) · {{.version}}</div>
|
||||
<div><a href="/">返回首页</a><a href="/logout">退出</a></div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<button class="btn-disable" onclick="disableAll()">一键全部关闭</button>
|
||||
<button class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
|
||||
<button id="btnDisableAll" class="btn-disable" onclick="disableAll()">一键全部关闭</button>
|
||||
<button id="btnReload" class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
|
||||
</div>
|
||||
<div class="tip">默认推荐:直接点“保存并立即生效”。高级场景再用“保存草稿 / 发布草稿 / 热加载”。</div>
|
||||
<div class="tip">填写需要的参数即可(每项一个输入框)。高级 JSON 已折叠,默认不需要碰。</div>
|
||||
<div id="app"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let me=null;
|
||||
const app = document.getElementById('app');
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const r = await fetch(url, options);
|
||||
const out = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(out.message || ('HTTP ' + r.status));
|
||||
return out?.data || {};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
|
||||
function renderError(msg) {
|
||||
app.innerHTML = `<div class="card" style="border:1px solid #fecaca;background:#fef2f2;color:#991b1b;">${msg}</div>`;
|
||||
}
|
||||
@@ -85,15 +138,33 @@ function parseJSONSafe(text) {
|
||||
try { return JSON.parse(text || '{}'); } catch { return null; }
|
||||
}
|
||||
|
||||
function getFieldDefs(platform){
|
||||
if(platform==='telegram'){
|
||||
return [
|
||||
{key:'token', label:'Telegram Bot Token'}
|
||||
];
|
||||
}
|
||||
if(platform==='qqbot_official'){
|
||||
return [
|
||||
{key:'appid', label:'QQ Bot AppID'},
|
||||
{key:'secret', label:'QQ Bot Secret'}
|
||||
];
|
||||
}
|
||||
if(platform==='feishu'){
|
||||
return [
|
||||
{key:'app_id', label:'飞书 AppID'},
|
||||
{key:'app_secret', label:'飞书 AppSecret'},
|
||||
{key:'verification_token', label:'飞书 VerificationToken(可选)'},
|
||||
{key:'encrypt_key', label:'飞书 EncryptKey(可选)'}
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function fetchChannels() {
|
||||
const r = await fetch('/api/v1/admin/channels');
|
||||
if (!r.ok) {
|
||||
throw new Error('加载渠道失败: HTTP ' + r.status);
|
||||
}
|
||||
const data = await r.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('渠道返回格式异常');
|
||||
}
|
||||
const out = await api('/api/v1/admin/channels');
|
||||
const data = out?.channels;
|
||||
if (!Array.isArray(data)) throw new Error('渠道返回格式异常');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -101,19 +172,32 @@ function render(channels) {
|
||||
app.innerHTML = channels.map(ch => {
|
||||
const draftCfg = pretty(ch.draft_config_json || ch.config_json);
|
||||
const draftSec = pretty(ch.draft_secrets || ch.secrets);
|
||||
return `<div class="card" data-platform="${ch.platform}">
|
||||
<h3>${ch.name || ch.platform} ${statusBadge(ch.status)} ${runtimeState(ch)} ${ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : ''}</h3>
|
||||
<small>平台:${ch.platform} | 发布:${ch.published_at || '-'} | 最近检测:${ch.last_check_at || '-'}</small>
|
||||
const secObj = parseJSONSafe(draftSec) || {};
|
||||
const hasDraft = ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : '';
|
||||
const fields = getFieldDefs(ch.platform);
|
||||
const fieldsHtml = fields.map(f => {
|
||||
let v = secObj[f.key] || '';
|
||||
if (String(v).trim() === '***') { v = ''; }
|
||||
return `<div class="row"><label>${esc(f.label)}</label><input class="field" data-key="${esc(f.key)}" value="${esc(v)}" placeholder="留空表示不修改"></div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="card" data-platform="${esc(ch.platform)}">
|
||||
<h3>${esc(ch.name || ch.platform)} ${statusBadge(ch.status)} ${runtimeState(ch)} ${hasDraft}</h3>
|
||||
<small>平台:${esc(ch.platform)} | 发布:${esc(ch.published_at || '-')} | 最近检测:${esc(ch.last_check_at || '-')}</small>
|
||||
<div class="row"><label>启用</label><input type="checkbox" class="enabled" ${ch.enabled ? 'checked' : ''}></div>
|
||||
<div class="row"><label>显示名称</label><input class="name" value="${(ch.name||'').replace(/"/g,'"')}"></div>
|
||||
<div class="row"><label>草稿 config(JSON)</label><textarea class="config">${draftCfg}</textarea></div>
|
||||
<div class="row"><label>草稿 secrets(JSON)</label><textarea class="secrets">${draftSec}</textarea></div>
|
||||
<div class="row"><label>显示名称</label><input class="name" value="${esc(ch.name||'')}"></div>
|
||||
${fieldsHtml}
|
||||
<div class="btns">
|
||||
<button class="btn-apply" onclick="applyNow('${ch.platform}')">保存并立即生效</button>
|
||||
<button class="btn-save" onclick="saveDraft('${ch.platform}')">保存草稿</button>
|
||||
<button class="btn-publish" onclick="publishDraft('${ch.platform}')">发布草稿</button>
|
||||
<button class="btn-test" onclick="testConn('${ch.platform}')">测试连接</button>
|
||||
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${ch.platform}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${ch.platform}', true)">开启通道</button>`}
|
||||
<button class="btn-apply" onclick="applyNow('${esc(ch.platform)}')">保存并立即生效</button>
|
||||
<button class="btn-save" onclick="saveDraft('${esc(ch.platform)}')">保存草稿</button>
|
||||
<button class="btn-publish" onclick="publishDraft('${esc(ch.platform)}')">发布草稿</button>
|
||||
<button class="btn-test" onclick="testConn('${esc(ch.platform)}')">测试连接</button>
|
||||
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${esc(ch.platform)}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${esc(ch.platform)}', true)">开启通道</button>`}
|
||||
<button class="btn-ghost" onclick="toggleAdvanced('${esc(ch.platform)}')">高级 JSON</button>
|
||||
</div>
|
||||
<div class="advanced hidden">
|
||||
<div class="row"><label>配置 JSON</label><textarea class="config">${draftCfg}</textarea></div>
|
||||
<div class="row"><label>密钥 JSON</label><textarea class="secrets">${draftSec}</textarea></div>
|
||||
</div>
|
||||
<small class="msg"></small>
|
||||
</div>`;
|
||||
@@ -124,31 +208,33 @@ function collectChannelForm(platform) {
|
||||
const card = document.querySelector(`[data-platform="${platform}"]`);
|
||||
const name = card.querySelector('.name').value.trim();
|
||||
const enabled = card.querySelector('.enabled').checked;
|
||||
const configText = card.querySelector('.config').value;
|
||||
const secretsText = card.querySelector('.secrets').value;
|
||||
const cfgText = card.querySelector('.config') ? card.querySelector('.config').value : '{}';
|
||||
const secText = card.querySelector('.secrets') ? card.querySelector('.secrets').value : '{}';
|
||||
const msg = card.querySelector('.msg');
|
||||
|
||||
const config = parseJSONSafe(configText);
|
||||
const secrets = parseJSONSafe(secretsText);
|
||||
if (!config || !secrets) {
|
||||
msg.textContent = 'JSON 格式错误,请检查 config/secrets';
|
||||
const config = parseJSONSafe(cfgText) || {};
|
||||
const secrets = parseJSONSafe(secText) || {};
|
||||
|
||||
if (config === null || secrets === null) {
|
||||
msg.textContent = 'JSON 格式错误,请检查高级 JSON';
|
||||
return null;
|
||||
}
|
||||
|
||||
return { card, msg, payload: { name, enabled, config, secrets } };
|
||||
// 覆盖结构化字段,但保留未知字段
|
||||
let hasSecretChange = false;
|
||||
card.querySelectorAll('.field').forEach(input => {
|
||||
const key = input.getAttribute('data-key');
|
||||
const val = (input.value || '').trim();
|
||||
if (val !== '') {
|
||||
secrets[key] = val;
|
||||
hasSecretChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
async function apiJSON(url, options = {}) {
|
||||
const r = await fetch(url, options);
|
||||
const out = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
throw new Error(out.error || ('HTTP ' + r.status));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const payload = { name, enabled, config };
|
||||
if (hasSecretChange) payload.secrets = secrets;
|
||||
|
||||
function msgOf(platform) {
|
||||
return document.querySelector(`[data-platform="${platform}"] .msg`);
|
||||
return { card, msg, payload };
|
||||
}
|
||||
|
||||
function setCardBusy(card, busy) {
|
||||
@@ -168,7 +254,7 @@ async function applyNow(platform) {
|
||||
|
||||
try {
|
||||
msg.textContent = '保存并生效中...';
|
||||
await apiJSON('/api/v1/admin/channels/' + platform + '/apply', {
|
||||
await api('/api/v1/admin/channels/' + platform + '/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
@@ -189,7 +275,7 @@ async function saveDraft(platform) {
|
||||
const { msg, payload } = f;
|
||||
|
||||
try {
|
||||
await apiJSON('/api/v1/admin/channels/' + platform, {
|
||||
await api('/api/v1/admin/channels/' + platform, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
@@ -204,7 +290,7 @@ async function saveDraft(platform) {
|
||||
async function publishDraft(platform) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
await apiJSON('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
|
||||
await api('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
|
||||
msg.textContent = '发布成功,建议点“热加载运行参数”';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
@@ -216,8 +302,8 @@ async function testConn(platform) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
msg.textContent = '正在测试...';
|
||||
const out = await apiJSON('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
|
||||
msg.textContent = `测试结果:${out.status} ${out.detail ? ' / ' + out.detail : ''}`;
|
||||
const out = await api('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
|
||||
msg.textContent = `测试结果:${out.status || 'unknown'} ${out.detail ? ' / ' + out.detail : ''}`;
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '测试失败:' + (e && e.message ? e.message : e);
|
||||
@@ -228,7 +314,7 @@ async function toggleChannel(platform, enable) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
msg.textContent = enable ? '正在开启...' : '正在关闭...';
|
||||
await apiJSON('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
|
||||
await api('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
|
||||
msg.textContent = enable ? '已开启(请点热加载生效)' : '已关闭(请点热加载生效)';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
@@ -238,7 +324,7 @@ async function toggleChannel(platform, enable) {
|
||||
|
||||
async function reloadRuntime() {
|
||||
try {
|
||||
const out = await apiJSON('/api/v1/admin/channels/reload', { method: 'POST' });
|
||||
const out = await api('/api/v1/admin/channels/reload', { method: 'POST' });
|
||||
alert('热加载成功:' + (out.detail || 'ok'));
|
||||
await reload();
|
||||
} catch (e) {
|
||||
@@ -249,7 +335,7 @@ async function reloadRuntime() {
|
||||
async function disableAll() {
|
||||
if (!confirm('确认要关闭所有通道吗?')) return;
|
||||
try {
|
||||
const out = await apiJSON('/api/v1/admin/channels/disable-all', { method: 'POST' });
|
||||
const out = await api('/api/v1/admin/channels/disable-all', { method: 'POST' });
|
||||
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
|
||||
await reload();
|
||||
} catch (e) {
|
||||
@@ -257,6 +343,48 @@ async function disableAll() {
|
||||
}
|
||||
}
|
||||
|
||||
function msgOf(platform) {
|
||||
return document.querySelector(`[data-platform="${platform}"] .msg`);
|
||||
}
|
||||
|
||||
function toggleAdvanced(platform){
|
||||
const card = document.querySelector(`[data-platform="${platform}"]`);
|
||||
if(!card) return;
|
||||
const adv = card.querySelector('.advanced');
|
||||
if(adv) adv.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
function initPermissionUI(){
|
||||
document.getElementById('btnReload').classList.toggle('hidden',!can('can_edit_channels'));
|
||||
document.getElementById('btnDisableAll').classList.toggle('hidden',!can('can_edit_channels'));
|
||||
}
|
||||
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const channels = await fetchChannels();
|
||||
@@ -276,7 +404,10 @@ window.addEventListener('unhandledrejection', (e) => {
|
||||
renderError('前端请求异常:' + msg);
|
||||
});
|
||||
|
||||
reload();
|
||||
(async function(){
|
||||
try{ initTheme(); await loadMe(); initPermissionUI(); await reload(); }
|
||||
catch(e){ renderError('初始化失败:'+(e.message||e)); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CPA 配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;
|
||||
--accent:#ee5a24;--accent-hover:#d63031;--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;
|
||||
--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
|
||||
.header button{width:28px;padding:0;transition:transform .2s ease}
|
||||
.header button.spin{transform:rotate(180deg)}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
button.danger{background:#9b1c1c}
|
||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
||||
small{color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🔧 CPA 配置 · {{.version}}</div>
|
||||
<div class="right">
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>CPA 管理接口</h3>
|
||||
<div class="row"><label>CPA Management Base</label><input id="base" placeholder="https://cpa.pao.xx.kg/v0/management"></div>
|
||||
<div class="row"><label>CPA Management Token</label><input id="token" placeholder="请输入 Token"></div>
|
||||
<small>用于访问 CPA 管理接口(Bearer Token)</small>
|
||||
<div class="btns">
|
||||
<button onclick="save()">保存</button>
|
||||
<button class="secondary" onclick="load()">刷新</button>
|
||||
</div>
|
||||
<small id="msg"></small>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>目标主机(Ops Targets)</h3>
|
||||
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
|
||||
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
|
||||
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
|
||||
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
|
||||
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
|
||||
<div class="btns"><button onclick="createTarget()">新增</button><button class="secondary" onclick="loadTargets()">刷新列表</button></div>
|
||||
<small id="tmsg"></small>
|
||||
<div style="margin-top:10px;">
|
||||
<table class="table" style="width:100%;border-collapse:collapse;font-size:13px;">
|
||||
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/cpa/settings');
|
||||
document.getElementById('token').value = d.settings?.cpa_management_token || '';
|
||||
document.getElementById('base').value = d.settings?.cpa_management_base || '';
|
||||
document.getElementById('msg').textContent='已加载';
|
||||
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(){
|
||||
const token=document.getElementById('token').value.trim();
|
||||
const base=document.getElementById('base').value.trim();
|
||||
try{
|
||||
await api('/api/v1/admin/cpa/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({management_token:token,management_base:base})});
|
||||
document.getElementById('msg').textContent='已保存';
|
||||
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function loadTargets(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/ops/targets');
|
||||
const list=Array.isArray(d.targets)?d.targets:[];
|
||||
const tbody=document.getElementById('tbody');
|
||||
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
|
||||
tbody.innerHTML=list.map(t=>`<tr>
|
||||
<td>${esc(t.name)}</td>
|
||||
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
|
||||
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
|
||||
<td>
|
||||
<button class="secondary" onclick="saveTarget(${t.id})">保存</button>
|
||||
<button class="danger" onclick="toggleTarget(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}catch(e){document.getElementById('tmsg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function createTarget(){
|
||||
const payload={
|
||||
name:document.getElementById('name').value.trim(),
|
||||
host:document.getElementById('host').value.trim(),
|
||||
port:parseInt(document.getElementById('port').value||'22',10),
|
||||
user:document.getElementById('user').value.trim(),
|
||||
enabled:document.getElementById('enabled').checked
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('tmsg').textContent='已新增';
|
||||
await loadTargets();
|
||||
}catch(e){document.getElementById('tmsg').textContent='失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function saveTarget(id){
|
||||
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
|
||||
const payload={enabled:true};
|
||||
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
|
||||
payload.port=parseInt(payload.port||'22',10);
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
await loadTargets();
|
||||
}catch(e){alert('保存失败:'+(e.message||e));}
|
||||
}
|
||||
async function toggleTarget(id, enable){
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
|
||||
await loadTargets();
|
||||
}catch(e){alert('失败:'+(e.message||e));}
|
||||
}
|
||||
function setThemeIcon(theme){
|
||||
const icon=document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
if(theme==='dark'){
|
||||
// 月亮:同线条风格
|
||||
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
||||
}else{
|
||||
// 太阳:保留原样式
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle>'+
|
||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
||||
}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
||||
else{document.documentElement.removeAttribute('data-theme');}
|
||||
localStorage.setItem('theme',theme);
|
||||
setThemeIcon(theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
const next = cur==='light'?'dark':'light';
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
||||
applyTheme(next);
|
||||
}
|
||||
(function(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.addEventListener('click',toggleTheme)}
|
||||
load();
|
||||
loadTargets();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+219
-197
@@ -3,234 +3,256 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🦞 虾记记账</title>
|
||||
<title>🛠️ Ops-Assistant</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333;min-height:100vh}
|
||||
.header{background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;padding:20px;text-align:center;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);position:relative}
|
||||
.header h1{font-size:24px;margin-bottom:4px}
|
||||
.header .subtitle{font-size:13px;opacity:.9}
|
||||
.logout-btn{position:absolute;right:16px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,.2);color:#fff;text-decoration:none;padding:5px 14px;border-radius:6px;font-size:13px}
|
||||
.logout-btn:hover{background:rgba(255,255,255,.35)}
|
||||
|
||||
.stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;padding:15px;max-width:600px;margin:0 auto}
|
||||
.stat-card{background:#fff;border-radius:12px;padding:15px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.06)}
|
||||
.stat-card .num{font-size:22px;font-weight:700;color:#ee5a24}
|
||||
.stat-card .label{font-size:12px;color:#999;margin-top:4px}
|
||||
|
||||
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:0 15px 10px;max-width:600px;margin:0 auto}
|
||||
.toolbar .spacer{flex:1}
|
||||
.filter-select{padding:8px 12px;border:1px solid #ddd;border-radius:8px;font-size:13px;background:#fff;min-width:110px}
|
||||
.toolbar a{padding:8px 12px;background:#ee5a24;color:#fff;border-radius:8px;text-decoration:none;font-size:13px;white-space:nowrap}
|
||||
.toolbar a:hover{background:#d63031}
|
||||
|
||||
#flagsPanel{display:none;max-width:600px;margin:0 auto 12px;background:#fff;border-radius:12px;padding:12px 14px;box-shadow:0 2px 8px rgba(0,0,0,.06)}
|
||||
#flagsList{display:flex;flex-direction:column;gap:8px}
|
||||
|
||||
.tx-list{padding:0 15px 80px;max-width:600px;margin:0 auto}
|
||||
.tx-card{background:#fff;border-radius:12px;padding:14px 16px;margin-bottom:10px;box-shadow:0 1px 4px rgba(0,0,0,.05);display:flex;align-items:center;gap:12px}
|
||||
.tx-icon{width:42px;height:42px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
|
||||
.tx-info{flex:1;min-width:0}
|
||||
.tx-category{font-weight:600;font-size:15px}
|
||||
.tx-note{font-size:12px;color:#999;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.tx-right{text-align:right;flex-shrink:0}
|
||||
.tx-amount{font-size:17px;font-weight:700;color:#e74c3c}
|
||||
.tx-date{font-size:11px;color:#bbb;margin-top:2px}
|
||||
.btn-del{background:none;border:1px solid #e74c3c;color:#e74c3c;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;margin-top:4px}
|
||||
.btn-del:hover{background:#e74c3c;color:#fff}
|
||||
|
||||
.empty{text-align:center;padding:60px 20px;color:#999}
|
||||
.empty .icon{font-size:48px;margin-bottom:10px}
|
||||
.cat-餐饮{background:#fff3e0}.cat-交通{background:#e3f2fd}.cat-购物{background:#fce4ec}.cat-娱乐{background:#f3e5f5}.cat-其他{background:#f5f5f5}
|
||||
|
||||
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:200;align-items:center;justify-content:center}
|
||||
.modal.show{display:flex}
|
||||
.modal-body{background:#fff;border-radius:16px;padding:24px;width:280px;text-align:center}
|
||||
.modal-body .btns{display:flex;gap:10px;margin-top:16px}
|
||||
.modal-body .btns button{flex:1;padding:10px;border:none;border-radius:8px;cursor:pointer}
|
||||
|
||||
@media(max-width:640px){
|
||||
.header{padding:14px 12px 10px;text-align:left}
|
||||
.header h1{font-size:20px;margin-right:90px}
|
||||
.header .subtitle{font-size:12px;margin-right:90px}
|
||||
.logout-btn{right:10px;top:14px;transform:none;padding:4px 10px;font-size:12px}
|
||||
.stats{grid-template-columns:1fr;padding:12px}
|
||||
.stat-card{text-align:left;padding:12px}
|
||||
.toolbar{padding:0 12px 10px}
|
||||
.toolbar .spacer{display:none}
|
||||
.toolbar a,.filter-select{flex:1 1 calc(50% - 4px);text-align:center}
|
||||
#flagsPanel{margin:0 12px 12px}
|
||||
.tx-list{padding:0 12px 72px}
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
--chip:#f3f4f6;
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
--chip:#111827;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.header h1{font-size:22px}
|
||||
.header .subtitle{font-size:12px;opacity:.9}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header .right a,.header .right button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.header .right button{width:28px;padding:0;transition:transform .2s ease}
|
||||
.header .right button.spin{transform:rotate(180deg)}
|
||||
.header .right button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none}
|
||||
.card h3{font-size:14px;margin-bottom:10px}
|
||||
.stat{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;font-size:13px;color:var(--muted)}
|
||||
.stat b{font-size:18px;color:var(--accent)}
|
||||
|
||||
.section{margin-top:12px}
|
||||
.section-title{font-size:13px;color:var(--muted);margin:10px 2px}
|
||||
.tags{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.tag{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;background:var(--card)}
|
||||
.tag .dot{width:8px;height:8px;border-radius:50%}
|
||||
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}.dot.off{background:#9ca3af}
|
||||
|
||||
.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
||||
.actions a{display:inline-block;text-decoration:none;background:var(--accent);color:#fff;border-radius:6px;padding:8px 12px;font-size:13px;border:1px solid rgba(0,0,0,.06)}
|
||||
.actions a.secondary{background:#6b7280}
|
||||
.actions a.hidden{display:none}
|
||||
|
||||
.list{display:grid;gap:8px}
|
||||
.job{border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;background:var(--card)}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.pending{background:#fef3c7;color:#92400e}.running{background:#dbeafe;color:#1e3a8a}.success{background:#dcfce7;color:#166534}.failed{background:#fee2e2;color:#991b1b}.cancelled{background:#e5e7eb;color:#374151}
|
||||
|
||||
.empty{padding:30px;text-align:center;color:var(--muted)}
|
||||
|
||||
@media(max-width:720px){
|
||||
.grid{grid-template-columns:1fr}
|
||||
.header{flex-direction:column;align-items:flex-start}
|
||||
.header .right{align-self:flex-end}
|
||||
}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>🦞 虾记记账</h1>
|
||||
<div>
|
||||
<h1>🛠️ Ops-Assistant</h1>
|
||||
<div class="subtitle">{{.version}}</div>
|
||||
<a href="/logout" class="logout-btn">退出</a>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card"><div class="num" id="todayTotal">0.00</div><div class="label">今日支出</div></div>
|
||||
<div class="stat-card"><div class="num" id="monthTotal">0.00</div><div class="label">本月支出</div></div>
|
||||
<div class="stat-card"><div class="num" id="txCount">0</div><div class="label">总记录数</div></div>
|
||||
<div class="wrap">
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>任务概览</h3>
|
||||
<div class="stat"><span>Pending</span><b id="sPending">0</b></div>
|
||||
<div class="stat"><span>Running</span><b id="sRunning">0</b></div>
|
||||
<div class="stat"><span>Success</span><b id="sSuccess">0</b></div>
|
||||
<div class="stat"><span>Failed</span><b id="sFailed">0</b></div>
|
||||
<div class="stat"><span>Cancelled</span><b id="sCancelled">0</b></div>
|
||||
<div class="actions">
|
||||
<a id="btnOps" href="/ops" class="hidden">🛠️ 任务中心</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>模块状态</h3>
|
||||
<div class="tags" id="modulesTag"></div>
|
||||
<div class="actions">
|
||||
<a id="btnCPA" href="/cpa" class="secondary hidden">🔧 CPA 配置</a>
|
||||
<a id="btnCF" href="/cf" class="secondary hidden">☁️ CF 配置</a>
|
||||
<a id="btnCFDAV" href="/cfdav" class="secondary hidden">🗂️ cfdav 配置</a>
|
||||
<a id="btnAI" href="/ai" class="secondary hidden" style="display:none">🤖 AI 配置</a>
|
||||
<a id="btnModules" href="#" class="secondary hidden" onclick="toggleModulesPanel();return false;">⚙️ 模块开关</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>通道状态</h3>
|
||||
<div class="tags" id="channelsTag"></div>
|
||||
<div class="actions">
|
||||
<a id="btnChannels" href="/channels" class="hidden">🔌 渠道配置</a>
|
||||
<a id="btnAudit" href="/audit" class="secondary hidden">🧾 审计日志</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<select class="filter-select" id="catFilter" onchange="filterList()"><option value="">全部分类</option></select>
|
||||
<select class="filter-select" id="scopeFilter" onchange="loadData()" style="display:none;"><option value="self">仅本人</option><option value="all">全员</option></select>
|
||||
<div class="spacer"></div>
|
||||
<a href="#" id="btnFlags" style="display:none;">⚙️ 高级功能</a>
|
||||
<a href="/audit" id="btnAudit" style="display:none;">🧾 审计日志</a>
|
||||
<a href="/channels" id="btnChannels" style="display:none;">🔌 渠道配置</a>
|
||||
<a href="#" id="btnExport">📥 导出CSV</a>
|
||||
</div>
|
||||
|
||||
<div id="flagsPanel">
|
||||
<div style="font-weight:600;margin-bottom:8px;">高级功能开关(高风险默认关闭)</div>
|
||||
<div id="flagsList"></div>
|
||||
</div>
|
||||
|
||||
<div class="tx-list" id="txList"></div>
|
||||
|
||||
<div class="modal" id="delModal">
|
||||
<div class="modal-body">
|
||||
<h3>确认删除?</h3>
|
||||
<p id="delInfo" style="font-size:13px;color:#666;"></p>
|
||||
<div class="btns">
|
||||
<button onclick="closeModal()">取消</button>
|
||||
<button style="background:#e74c3c;color:#fff" onclick="confirmDelete()">删除</button>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">最近任务</div>
|
||||
<div id="recentJobs" class="list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const catIcons={ '餐饮':'🍜','交通':'🚗','购物':'🛒','娱乐':'🎮','住房':'🏠','通讯':'📱','医疗':'💊','教育':'📚','其他':'📦' };
|
||||
let allData=[],deleteId=null,me=null,flags=[];
|
||||
let me=null;
|
||||
const state={overview:null};
|
||||
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
|
||||
async function loadMe(){ const r=await fetch('/api/v1/me'); if(!r.ok) throw new Error('读取用户信息失败'); me=await r.json(); }
|
||||
|
||||
function initPermissionUI(){
|
||||
const caps=(me&&me.effective_capabilities)||{};
|
||||
const scopeSel=document.getElementById('scopeFilter');
|
||||
const btnChannels=document.getElementById('btnChannels');
|
||||
const btnAudit=document.getElementById('btnAudit');
|
||||
const btnFlags=document.getElementById('btnFlags');
|
||||
|
||||
scopeSel.style.display=(caps.can_read_all || (me.flags&&me.flags.allow_cross_user_read===false)) ? '' : 'none';
|
||||
btnChannels.style.display=caps.can_view_channels ? '' : 'none';
|
||||
btnAudit.style.display=caps.can_view_audit ? '' : 'none';
|
||||
btnFlags.style.display=caps.can_view_flags ? '' : 'none';
|
||||
|
||||
btnFlags.onclick=async (e)=>{
|
||||
e.preventDefault();
|
||||
const p=document.getElementById('flagsPanel');
|
||||
p.style.display=(p.style.display==='none'||!p.style.display)?'block':'none';
|
||||
if(p.style.display==='block'){ await loadFlags(); renderFlags(); }
|
||||
};
|
||||
|
||||
document.getElementById('btnExport').onclick=(e)=>{
|
||||
e.preventDefault();
|
||||
const scope=document.getElementById('scopeFilter').value||'self';
|
||||
if(scope==='all'&&!caps.can_export_all){ alert('没有导出全员权限或开关未启用'); return; }
|
||||
if(scope==='self'&&!caps.can_export_self){ alert('没有导出本人权限'); return; }
|
||||
window.location.href='/api/v1/export?scope='+encodeURIComponent(scope);
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFlags(){
|
||||
const caps=(me&&me.effective_capabilities)||{};
|
||||
if(!caps.can_view_flags) return;
|
||||
const r=await fetch('/api/v1/admin/settings/flags');
|
||||
const data=await r.json();
|
||||
flags=Array.isArray(data)?data:[];
|
||||
}
|
||||
|
||||
function renderFlags(){
|
||||
const list=document.getElementById('flagsList');
|
||||
const caps=(me&&me.effective_capabilities)||{};
|
||||
list.innerHTML=flags.map(f=>{
|
||||
const disabled=!caps.can_edit_flags?'disabled':'';
|
||||
return `<label style="display:flex;align-items:center;justify-content:space-between;border:1px solid #eef2f7;border-radius:8px;padding:8px 10px;">
|
||||
<span><strong>${esc(f.key)}</strong><br><small>${esc(f.description||'')}(${esc(f.risk_level||'unknown')})</small></span>
|
||||
<input type="checkbox" ${f.enabled?'checked':''} ${disabled} onchange="toggleFlag('${esc(f.key)}',this.checked)">
|
||||
</label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleFlag(key,enabled){
|
||||
const caps=(me&&me.effective_capabilities)||{};
|
||||
if(!caps.can_edit_flags){ alert('无修改权限'); return; }
|
||||
const reason=prompt('请输入修改原因(审计必填)')||'';
|
||||
const r=await fetch('/api/v1/admin/settings/flags/'+encodeURIComponent(key),{
|
||||
method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled,reason})
|
||||
});
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok){ alert(out.error||'修改失败'); }
|
||||
await loadMe(); await loadFlags(); renderFlags();
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
async function loadData(){
|
||||
try{
|
||||
const caps=(me&&me.effective_capabilities)||{};
|
||||
const scopeEl=document.getElementById('scopeFilter');
|
||||
let scope=scopeEl.value||'self';
|
||||
if(scope==='all'&&!caps.can_read_all){ alert('没有查看全员数据权限或开关未启用'); scope='self'; scopeEl.value='self'; }
|
||||
const r=await fetch('/api/v1/records?scope='+encodeURIComponent(scope));
|
||||
allData=await r.json();
|
||||
if(!Array.isArray(allData)) allData=[];
|
||||
renderStats(); renderList(allData); populateFilter();
|
||||
}catch(e){
|
||||
console.error(e);
|
||||
document.getElementById('txList').innerHTML='<div class="empty"><div class="icon">⚠️</div>数据加载失败</div>';
|
||||
}
|
||||
function initUI(){
|
||||
document.getElementById('btnOps').classList.toggle('hidden',!can('can_view_ops'));
|
||||
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
|
||||
document.getElementById('btnCPA').classList.toggle('hidden',!can('can_view_flags'));
|
||||
document.getElementById('btnCF').classList.toggle('hidden',!can('can_view_flags'));
|
||||
document.getElementById('btnCFDAV').classList.toggle('hidden',!can('can_view_flags'));
|
||||
document.getElementById('btnAI').classList.toggle('hidden',!can('can_view_flags'));
|
||||
document.getElementById('btnAudit').classList.toggle('hidden',!can('can_view_audit'));
|
||||
document.getElementById('btnModules').classList.toggle('hidden',!can('can_view_flags'));
|
||||
}
|
||||
|
||||
function renderStats(){
|
||||
const today=new Date().toISOString().slice(0,10), month=today.slice(0,7);
|
||||
let todaySum=0,monthSum=0;
|
||||
allData.forEach(tx=>{ const amt=Number(tx.amount||0); if(tx.date===today) todaySum+=amt; if(tx.date&&tx.date.startsWith(month)) monthSum+=amt; });
|
||||
document.getElementById('todayTotal').textContent=todaySum.toFixed(2);
|
||||
document.getElementById('monthTotal').textContent=monthSum.toFixed(2);
|
||||
document.getElementById('txCount').textContent=allData.length;
|
||||
function renderOverview(){
|
||||
const ov=state.overview||{};
|
||||
const sc=ov.jobs?.status_count||{};
|
||||
document.getElementById('sPending').textContent=sc.pending??0;
|
||||
document.getElementById('sRunning').textContent=sc.running??0;
|
||||
document.getElementById('sSuccess').textContent=sc.success??0;
|
||||
document.getElementById('sFailed').textContent=sc.failed??0;
|
||||
document.getElementById('sCancelled').textContent=sc.cancelled??0;
|
||||
|
||||
const mods=Array.isArray(ov.modules)?ov.modules:[];
|
||||
document.getElementById('modulesTag').innerHTML=mods.length?mods.map(m=>{
|
||||
const dot=m.enabled?'ok':'off';
|
||||
return `<span class="tag"><span class="dot ${dot}"></span>${esc(m.module)}</span>`;
|
||||
}).join(''):'<div class="empty">暂无模块</div>';
|
||||
|
||||
const chs=Array.isArray(ov.channels)?ov.channels:[];
|
||||
document.getElementById('channelsTag').innerHTML=chs.length?chs.map(c=>{
|
||||
const dot=c.status==='ok'?'ok':(c.enabled?'err':'off');
|
||||
return `<span class="tag"><span class="dot ${dot}"></span>${esc(c.platform)}</span>`;
|
||||
}).join(''):'<div class="empty">暂无通道</div>';
|
||||
|
||||
const jobs=Array.isArray(ov.jobs?.recent)?ov.jobs.recent:[];
|
||||
const box=document.getElementById('recentJobs');
|
||||
if(!jobs.length){ box.innerHTML='<div class="empty">暂无任务</div>'; return; }
|
||||
box.innerHTML=jobs.map(j=>`<div class="job">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;">
|
||||
<strong>#${j.id} ${esc(j.command||'')}</strong>
|
||||
<span class="badge ${(j.status||'pending').toLowerCase()}">${esc(j.status||'pending')}</span>
|
||||
</div>
|
||||
<div style="color:#666;margin-top:4px;">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function renderList(data){
|
||||
const el=document.getElementById('txList');
|
||||
const caps=(me&&me.effective_capabilities)||{};
|
||||
if(!data.length){ el.innerHTML='<div class="empty"><div class="icon">📭</div>暂无记录<br><small>通过 Telegram/QQ 发送消息记账</small></div>'; return; }
|
||||
el.innerHTML=data.map(tx=>{
|
||||
const icon=catIcons[tx.category]||'📦', cls='cat-'+(tx.category||'其他');
|
||||
const amount=Number(tx.amount||0), note=esc(tx.note), canDelete=caps.can_delete_self||caps.can_delete_all;
|
||||
const delBtn=canDelete ? `<button class="btn-del" onclick="showDelete(${tx.id},'${note.replace(/'/g,"'")}',${amount})">删除</button>` : `<button class="btn-del" style="opacity:.45;cursor:not-allowed;" onclick="alert('无删除权限')">删除</button>`;
|
||||
return `<div class="tx-card"><div class="tx-icon ${cls}">${icon}</div><div class="tx-info"><div class="tx-category">${esc(tx.category||'其他')}</div><div class="tx-note" title="${note}">${note}</div></div><div class="tx-right"><div class="tx-amount">-${amount.toFixed(2)}</div><div class="tx-date">${esc(tx.date||'')}</div>${delBtn}</div></div>`;
|
||||
}).join('');
|
||||
async function loadOverview(){
|
||||
state.overview=await api('/api/v1/dashboard/overview');
|
||||
renderOverview();
|
||||
}
|
||||
|
||||
function populateFilter(){
|
||||
const cats=[...new Set(allData.map(t=>t.category).filter(Boolean))].sort();
|
||||
const sel=document.getElementById('catFilter'), cur=sel.value;
|
||||
sel.innerHTML='<option value="">全部分类</option>'+cats.map(c=>`<option value="${esc(c)}">${esc(c)}</option>`).join('');
|
||||
sel.value=cur;
|
||||
function toggleModulesPanel(){
|
||||
window.location.href='/modules';
|
||||
}
|
||||
|
||||
function filterList(){ const cat=document.getElementById('catFilter').value; renderList(cat?allData.filter(t=>t.category===cat):allData); }
|
||||
function showDelete(id,note,amount){ deleteId=id; document.getElementById('delInfo').textContent=`${note} (${Number(amount).toFixed(2)}元)`; document.getElementById('delModal').classList.add('show'); }
|
||||
function closeModal(){ document.getElementById('delModal').classList.remove('show'); deleteId=null; }
|
||||
|
||||
async function confirmDelete(){
|
||||
if(!deleteId) return;
|
||||
const r=await fetch('/api/v1/records/'+deleteId+'/delete',{method:'POST'});
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) alert(out.error||'删除失败');
|
||||
closeModal(); loadData();
|
||||
function setThemeIcon(theme){
|
||||
const icon=document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
if(theme==='dark'){
|
||||
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
||||
}else{
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle>'+
|
||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
||||
}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
setThemeIcon(theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
const next=cur==='light'?'dark':'light';
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
||||
applyTheme(next);
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
(async function(){
|
||||
try{ await loadMe(); initPermissionUI(); await loadData(); setInterval(loadData,30000); }
|
||||
catch(e){ console.error(e); document.getElementById('txList').innerHTML='<div class="empty"><div class="icon">⚠️</div>页面初始化失败</div>'; }
|
||||
try{ initTheme(); await loadMe(); initUI(); await loadOverview(); setInterval(loadOverview,15000);}catch(e){
|
||||
document.getElementById('recentJobs').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+41
-25
@@ -3,26 +3,39 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🦞 虾记 - 登录</title>
|
||||
<title>🛠️ Ops-Assistant - 登录</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f172a;
|
||||
--card:#111827;
|
||||
--text:#e5e7eb;
|
||||
--muted:#9ca3af;
|
||||
--border:#1f2937;
|
||||
--accent:#f97316;
|
||||
--accent2:#ea580c;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #f39c12 100%);
|
||||
background: radial-gradient(1200px 600px at 10% 10%, rgba(249,115,22,.18), transparent 60%),
|
||||
radial-gradient(900px 500px at 90% 10%, rgba(234,88,12,.18), transparent 60%),
|
||||
var(--bg);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 40px 32px;
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 36px 30px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.35);
|
||||
animation: slideUp .4s ease;
|
||||
}
|
||||
|
||||
@@ -33,51 +46,54 @@ body {
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-logo .icon { font-size: 52px; }
|
||||
.login-logo .icon { font-size: 48px; }
|
||||
.login-logo h1 {
|
||||
font-size: 22px;
|
||||
color: #333;
|
||||
color: var(--text);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.login-logo .subtitle {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #eee;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
transition: border-color .2s;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
outline: none;
|
||||
background: #0b1220;
|
||||
color: var(--text);
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: #ee5a24;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(249,115,22,.2);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a24);
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity .2s, transform .1s;
|
||||
@@ -87,14 +103,14 @@ body {
|
||||
.btn-login:active { transform: scale(.98); }
|
||||
|
||||
.error-msg {
|
||||
background: #fff2f0;
|
||||
color: #e74c3c;
|
||||
background: rgba(239,68,68,.12);
|
||||
color: #fca5a5;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
text-align: center;
|
||||
border: 1px solid #fde2e0;
|
||||
border: 1px solid rgba(239,68,68,.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -102,7 +118,7 @@ body {
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<div class="icon">🦞</div>
|
||||
<h1>虾记记账</h1>
|
||||
<h1>Ops-Assistant</h1>
|
||||
<div class="subtitle">{{.version}}</div>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +135,7 @@ body {
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn-login">登 录</button>
|
||||
<button class="btn-login" type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>模块开关 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;--accent:#ee5a24;--accent-hover:#d63031;--ok:#16a34a;--off:#64748b;}
|
||||
[data-theme="dark"]{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--ok:#22c55e;--off:#94a3b8;}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
|
||||
.header button{width:28px;padding:0;transition:transform .2s ease}
|
||||
.header button.spin{transform:rotate(180deg)}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.table th,.table td{border-bottom:1px solid var(--border);padding:10px;text-align:left}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
||||
.btn{border:none;border-radius:6px;padding:6px 10px;cursor:pointer;color:#fff;background:var(--accent);font-size:12px}
|
||||
.btn.secondary{background:#6b7280}
|
||||
.btn.danger{background:#9b1c1c}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
input{padding:6px 8px;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card);color:var(--text)}
|
||||
small{color:var(--muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>⚙️ 模块开关 · {{.version}}</div>
|
||||
<div class="right">
|
||||
<button id="themeToggle" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content:space-between;">
|
||||
<div><strong>模块列表</strong> <small>(需要填写变更原因)</small></div>
|
||||
<div class="row">
|
||||
<input id="reason" placeholder="请输入变更原因" style="min-width:240px" />
|
||||
<button class="btn secondary" onclick="load()">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="msg" style="margin:8px 0;color:var(--muted)"></div>
|
||||
<table class="table">
|
||||
<thead><tr><th>模块</th><th>开关</th><th>Flag Key</th><th>操作</th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
let theme='dark';
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
async function load(){
|
||||
try{
|
||||
const data=await api('/api/v1/modules');
|
||||
const rows=(data.modules||[]).map(m=>{
|
||||
const badge=m.enabled?'on':'off';
|
||||
const label=m.enabled?'启用':'禁用';
|
||||
const next=!m.enabled;
|
||||
const btnClass=m.enabled?'danger':'btn';
|
||||
return `<tr>
|
||||
<td>${esc(m.display_name||m.module)}</td>
|
||||
<td><span class="badge ${badge}">${label}</span></td>
|
||||
<td><small>${esc(m.flag_key||'')}</small></td>
|
||||
<td><button class="btn ${btnClass}" onclick="toggle('${esc(m.module)}',${next})">${next?'启用':'禁用'}</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
document.getElementById('rows').innerHTML=rows || '<tr><td colspan="4">暂无模块</td></tr>';
|
||||
document.getElementById('msg').textContent='';
|
||||
}catch(e){
|
||||
document.getElementById('msg').textContent='加载失败:'+(e.message||e);
|
||||
}
|
||||
}
|
||||
async function toggle(module, enabled){
|
||||
const reason=(document.getElementById('reason').value||'').trim();
|
||||
if(!reason){alert('请填写变更原因');return;}
|
||||
try{
|
||||
await api('/api/v1/modules/'+module+'/toggle',{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({enabled,reason})
|
||||
});
|
||||
await load();
|
||||
}catch(e){alert('操作失败:'+(e.message||e));}
|
||||
}
|
||||
function setThemeIcon(theme){
|
||||
const icon=document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
if(theme==='dark'){
|
||||
icon.innerHTML='<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
||||
}else{
|
||||
icon.innerHTML='<circle cx="12" cy="12" r="5"></circle>'+
|
||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
||||
}
|
||||
}
|
||||
function applyTheme(t){
|
||||
theme=t; if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
||||
else{document.documentElement.removeAttribute('data-theme');}
|
||||
localStorage.setItem('theme',theme); setThemeIcon(theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const next=theme==='light'?'dark':'light';
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
||||
applyTheme(next);
|
||||
}
|
||||
(function(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🛠️ OPS任务 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer}
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:14px}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
input,select{padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
button.danger{background:#9b1c1c}
|
||||
.card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.pending{background:#fef3c7;color:#92400e}.running{background:#dbeafe;color:#1e3a8a}.success{background:#dcfce7;color:#166534}.failed{background:#fee2e2;color:#991b1b}.cancelled{background:#e5e7eb;color:#374151}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
|
||||
pre{background:#0b1020;color:#d1d5db;border-radius:6px;padding:8px;overflow:auto;font-size:12px;max-height:220px}
|
||||
.small{font-size:12px;color:var(--muted)}
|
||||
.empty{padding:24px;text-align:center;color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🛠️ OPS任务面板 · {{.version}}</div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<select id="qStatus">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
<input id="qTarget" placeholder="target (如 hwsg)">
|
||||
<input id="qRunbook" placeholder="runbook (如 cpa_usage_backup)">
|
||||
<input id="qRequest" placeholder="request_id">
|
||||
<input id="qOperator" placeholder="operator (user_id)">
|
||||
<input id="qFrom" placeholder="from (RFC3339)">
|
||||
<input id="qTo" placeholder="to (RFC3339)">
|
||||
<button onclick="loadJobs()">查询</button>
|
||||
<button class="secondary" onclick="resetFilter()">重置</button>
|
||||
</div>
|
||||
<div id="jobs"></div>
|
||||
</div>
|
||||
<script>
|
||||
let me=null;
|
||||
let pollTimer=null;
|
||||
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
function statusTag(s){const k=(s||'pending').toLowerCase();return `<span class="badge ${k}">${esc(k)}</span>`;}
|
||||
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
function buildQuery(){
|
||||
const q={
|
||||
status:document.getElementById('qStatus').value.trim(),
|
||||
target:document.getElementById('qTarget').value.trim(),
|
||||
runbook:document.getElementById('qRunbook').value.trim(),
|
||||
request_id:document.getElementById('qRequest').value.trim(),
|
||||
operator:document.getElementById('qOperator').value.trim(),
|
||||
from:document.getElementById('qFrom').value.trim(),
|
||||
to:document.getElementById('qTo').value.trim(),
|
||||
limit:'50'
|
||||
};
|
||||
const qs=Object.entries(q).filter(([,v])=>v).map(([k,v])=>k+'='+encodeURIComponent(v)).join('&');
|
||||
return '/api/v1/ops/jobs'+(qs?'?'+qs:'');
|
||||
}
|
||||
|
||||
async function loadJobs(){
|
||||
const box=document.getElementById('jobs');
|
||||
box.innerHTML='<div class="card">加载中...</div>';
|
||||
try{
|
||||
const data=await api(buildQuery());
|
||||
const jobs=Array.isArray(data.jobs)?data.jobs:[];
|
||||
if(!jobs.length){box.innerHTML='<div class="card empty">暂无任务</div>';return;}
|
||||
box.innerHTML=jobs.map(j=>`<div class="card">
|
||||
<div class="row"><strong>#${j.id} ${esc(j.command||'')}</strong>${statusTag(j.status)}</div>
|
||||
<div class="small">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')} · risk=${esc(j.risk_level||'-')}</div>
|
||||
<div class="mono">request=${esc(j.request_id||'-')} · start=${esc(j.started_at||'-')} · end=${esc(j.ended_at||'-')}</div>
|
||||
<div class="toolbar" style="margin-top:8px;">
|
||||
<button class="secondary" onclick="viewDetail(${j.id})">查看步骤</button>
|
||||
${can('can_cancel_ops')?`<button class="danger" onclick="cancelJob(${j.id},'${esc(j.status||'')}')">取消</button>`:''}
|
||||
${can('can_retry_ops')?`<button onclick="retryJob(${j.id},'${esc(j.status||'')}')">重试</button>`:''}
|
||||
</div>
|
||||
<div id="detail-${j.id}" class="small"></div>
|
||||
</div>`).join('');
|
||||
}catch(e){box.innerHTML='<div class="card">加载失败:'+esc(e.message||e)+'</div>';}
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
function schedulePoll(){
|
||||
if(pollTimer) clearTimeout(pollTimer);
|
||||
pollTimer=setTimeout(async ()=>{
|
||||
try{ await loadJobs(); }catch(e){}
|
||||
},8000);
|
||||
}
|
||||
|
||||
async function viewDetail(id){
|
||||
const el=document.getElementById('detail-'+id);
|
||||
el.textContent='加载步骤中...';
|
||||
try{
|
||||
const d=await api('/api/v1/ops/jobs/'+id);
|
||||
const steps=Array.isArray(d.steps)?d.steps:[];
|
||||
const stats=d.step_stats||{};
|
||||
const total=d.step_total||steps.length||0;
|
||||
const dur=d.duration||{};
|
||||
const head=`<div class="small" style="margin:6px 0;">steps=${total} · running=${stats.running||0} · success=${stats.success||0} · failed=${stats.failed||0} · job_ms=${dur.job_ms||0}</div>`;
|
||||
if(!steps.length){el.innerHTML=head+'无步骤';return;}
|
||||
el.innerHTML=head+steps.map(s=>`<div style="margin-top:6px;padding:6px;border:1px solid #eee;border-radius:8px;">
|
||||
<div><strong>${esc(s.step_id)}</strong> (${esc(s.action)}) ${statusTag(s.status)}</div>
|
||||
<div class="mono">rc=${s.rc} · ${esc(s.started_at||'')} -> ${esc(s.ended_at||'')}</div>
|
||||
<details><summary>stdout/stderr</summary><pre>${esc((s.stdout_tail||'')+'\n---\n'+(s.stderr_tail||''))}</pre></details>
|
||||
</div>`).join('');
|
||||
}catch(e){el.textContent='加载失败:'+(e.message||e);}
|
||||
}
|
||||
|
||||
async function cancelJob(id,status){
|
||||
if(!['pending','running'].includes(String(status||'').toLowerCase())){alert('仅 pending/running 可取消');return;}
|
||||
const reason=prompt('请输入取消原因(必填)')||'';
|
||||
if(!reason.trim()){alert('取消原因不能为空');return;}
|
||||
if(!confirm('确认取消任务 #'+id+' ?')) return;
|
||||
try{await api('/api/v1/ops/jobs/'+id+'/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已取消');loadJobs();}
|
||||
catch(e){alert('取消失败:'+(e.message||e));}
|
||||
}
|
||||
|
||||
async function retryJob(id,status){
|
||||
if(String(status||'').toLowerCase()!=='failed'){alert('仅 failed 可重试');return;}
|
||||
const reason=prompt('请输入重试原因(必填)')||'';
|
||||
if(!reason.trim()){alert('重试原因不能为空');return;}
|
||||
try{const d=await api('/api/v1/ops/jobs/'+id+'/retry',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已重试,新任务ID='+d.new_job_id);loadJobs();}
|
||||
catch(e){alert('重试失败:'+(e.message||e));}
|
||||
}
|
||||
|
||||
function resetFilter(){
|
||||
document.getElementById('qStatus').value='';
|
||||
document.getElementById('qTarget').value='';
|
||||
document.getElementById('qRunbook').value='';
|
||||
document.getElementById('qRequest').value='';
|
||||
document.getElementById('qOperator').value='';
|
||||
document.getElementById('qFrom').value='';
|
||||
document.getElementById('qTo').value='';
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
(async function(){
|
||||
try{initTheme();await loadMe();await loadJobs();}catch(e){document.getElementById('jobs').innerHTML='<div class="card">初始化失败:'+esc(e.message||e)+'</div>';}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>目标主机配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;--accent:#ee5a24;--accent-hover:#d63031;--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);}
|
||||
[data-theme="dark"]{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer}
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
|
||||
.row label{width:120px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:160px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
button.danger{background:#9b1c1c}
|
||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
||||
small{color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🎯 目标主机配置 · {{.version}}</div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>新增目标</h3>
|
||||
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
|
||||
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
|
||||
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
|
||||
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
|
||||
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
|
||||
<div class="row"><button onclick="create()">新增</button><small id="msg"></small></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>已有目标</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
const d=await api('/api/v1/admin/ops/targets');
|
||||
const list=Array.isArray(d.targets)?d.targets:[];
|
||||
const tbody=document.getElementById('tbody');
|
||||
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
|
||||
tbody.innerHTML=list.map(t=>`<tr>
|
||||
<td>${esc(t.name)}</td>
|
||||
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
|
||||
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
|
||||
<td>
|
||||
<button class="secondary" onclick="save(${t.id})">保存</button>
|
||||
<button class="danger" onclick="toggle(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
async function create(){
|
||||
const payload={
|
||||
name:document.getElementById('name').value.trim(),
|
||||
host:document.getElementById('host').value.trim(),
|
||||
port:parseInt(document.getElementById('port').value||'22',10),
|
||||
user:document.getElementById('user').value.trim(),
|
||||
enabled:document.getElementById('enabled').checked
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('msg').textContent='已新增';
|
||||
await load();
|
||||
}catch(e){document.getElementById('msg').textContent='失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(id){
|
||||
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
|
||||
const payload={enabled:true};
|
||||
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
|
||||
payload.port=parseInt(payload.port||'22',10);
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
await load();
|
||||
}catch(e){alert('保存失败:'+(e.message||e));}
|
||||
}
|
||||
async function toggle(id, enable){
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
|
||||
await load();
|
||||
}catch(e){alert('失败:'+(e.message||e));}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
||||
else{document.documentElement.removeAttribute('data-theme');}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
(function(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.addEventListener('click',toggleTheme)}
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -13,10 +13,10 @@ var (
|
||||
)
|
||||
|
||||
func Info() string {
|
||||
return fmt.Sprintf("Xiaji-Go %s (commit: %s, built: %s, %s %s/%s)",
|
||||
return fmt.Sprintf("Ops-Assistant %s (commit: %s, built: %s, %s %s/%s)",
|
||||
Version, GitCommit, BuildTime, GoVersion, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
|
||||
func Short() string {
|
||||
return fmt.Sprintf("Xiaji-Go %s", Version)
|
||||
return fmt.Sprintf("Ops-Assistant %s", Version)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user