commit 81deba4766d7402991aca206cc919748458b362f Author: OpenClaw Agent Date: Thu Mar 19 21:23:28 2026 +0800 init: ops-assistant codebase diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d13fcca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +*.db +config.yaml +ops-assistant +.git +.gitignore +README.md +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..01ccac3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# 二进制文件 +ops-assistant +ops-assistant-linux-amd64 + +# 数据库 +*.db + +# 配置文件(含敏感信息) +config.yaml + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Go +vendor/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac36459 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# ============ 构建阶段 ============ +FROM golang:1.22-bookworm AS builder + +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG BUILD_TIME=unknown + +WORKDIR /build + +# 安装 gojieba 依赖 +RUN apt-get update && apt-get install -y gcc g++ cmake && rm -rf /var/lib/apt/lists/* + +# 先复制依赖文件,利用缓存 +COPY go.mod go.sum ./ +RUN go mod download + +# 复制源码 +COPY . . + +# 编译 +RUN CGO_ENABLED=1 go build \ + -ldflags "-X ops-assistant/version.Version=${VERSION} \ + -X ops-assistant/version.GitCommit=${GIT_COMMIT} \ + -X ops-assistant/version.BuildTime=${BUILD_TIME}" \ + -o ops-assistant cmd/main.go + +# ============ 运行阶段 ============ +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/* + +ENV TZ=Asia/Shanghai + +WORKDIR /app + +# 从构建阶段复制 +COPY --from=builder /build/ops-assistant . +COPY --from=builder /build/templates/ ./templates/ +COPY --from=builder /build/config.yaml.example ./config.yaml.example + +# gojieba 词典文件 +COPY --from=builder /go/pkg/mod/github.com/yanyiwu/gojieba@v1.3.0/dict/ /app/dict/ + +# 数据目录 +VOLUME ["/app/data"] + +EXPOSE 9621 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:9621/health || exit 1 + +ENTRYPOINT ["./ops-assistant"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fd3292a --- /dev/null +++ b/Makefile @@ -0,0 +1,110 @@ +APP_NAME := ops-assistant +VERSION_FILE := VERSION +VERSION ?= $(shell cat $(VERSION_FILE) 2>/dev/null || echo "0.1.0") +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +LDFLAGS := -X ops-assistant/version.Version=$(VERSION) \ + -X ops-assistant/version.GitCommit=$(GIT_COMMIT) \ + -X ops-assistant/version.BuildTime=$(BUILD_TIME) + +DOCKER_IMAGE := ouaone/ops-assistant +DOCKER_TAG := $(VERSION) +RELEASE_TAG := v$(VERSION) +DIST_DIR := dist +ASSET_NAME := $(APP_NAME)-$(RELEASE_TAG)-linux-amd64 + +.PHONY: build clean run docker docker-push release help version show-version set-version bump-patch bump-minor bump-major release-plan package-release tag-release release-check + +## build: 编译二进制文件 +build: + go build -ldflags "$(LDFLAGS)" -o $(APP_NAME) cmd/main.go + +## build-linux: 交叉编译 Linux amd64 +build-linux: + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(APP_NAME)-linux-amd64 cmd/main.go + +## clean: 清理编译产物 +clean: + rm -f $(APP_NAME) $(APP_NAME)-linux-amd64 + +## run: 编译并运行 +run: build + ./$(APP_NAME) + +## docker: 构建 Docker 镜像 +docker: + docker build -t $(DOCKER_IMAGE):$(DOCKER_TAG) -t $(DOCKER_IMAGE):latest \ + --build-arg VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + --build-arg BUILD_TIME=$(BUILD_TIME) . + +## docker-push: 推送 Docker 镜像 +docker-push: + docker push $(DOCKER_IMAGE):$(DOCKER_TAG) + docker push $(DOCKER_IMAGE):latest + +## show-version: 显示语义化版本(vx.y.z) +show-version: + @echo "v$(VERSION)" + +## version: 显示版本信息 +version: + @echo "$(APP_NAME) v$(VERSION) ($(GIT_COMMIT))" + +## set-version: 设置版本号(示例: make set-version VERSION=1.2.3) +set-version: + @echo "$(VERSION)" > $(VERSION_FILE) + @echo "✅ version set to v$(VERSION)" + +## bump-patch: 补丁版本 +1(x.y.z -> x.y.z+1) +bump-patch: + @python3 -c 'from pathlib import Path;p=Path("VERSION");s=p.read_text().strip() if p.exists() else "0.1.0";a,b,c=[int(x) for x in s.split(".")[:3]];n=f"{a}.{b}.{c+1}";p.write_text(n+"\n");print(f"✅ bump patch: v{s} -> v{n}")' + +## bump-minor: 次版本 +1(x.y.z -> x.y+1.0) +bump-minor: + @python3 -c 'from pathlib import Path;p=Path("VERSION");s=p.read_text().strip() if p.exists() else "0.1.0";a,b,c=[int(x) for x in s.split(".")[:3]];n=f"{a}.{b+1}.0";p.write_text(n+"\n");print(f"✅ bump minor: v{s} -> v{n}")' + +## bump-major: 主版本 +1(x.y.z -> x+1.0.0) +bump-major: + @python3 -c 'from pathlib import Path;p=Path("VERSION");s=p.read_text().strip() if p.exists() else "0.1.0";a,b,c=[int(x) for x in s.split(".")[:3]];n=f"{a+1}.0.0";p.write_text(n+"\n");print(f"✅ bump major: v{s} -> v{n}")' + +## release-plan: 显示发布参数预览 +release-plan: + @echo "APP_NAME=$(APP_NAME)" + @echo "VERSION=$(VERSION)" + @echo "RELEASE_TAG=$(RELEASE_TAG)" + @echo "DOCKER_IMAGE=$(DOCKER_IMAGE)" + @echo "DOCKER_TAG=$(DOCKER_TAG)" + @echo "ASSET=$(DIST_DIR)/$(ASSET_NAME)" + +## release-check: 发布前基础检查(工作区、版本格式) +release-check: + @test -z "$$(git status --porcelain)" || (echo "❌ git 工作区不干净,请先提交" && exit 1) + @echo "$(VERSION)" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$$' || (echo "❌ VERSION 格式必须是 x.y.z" && exit 1) + @echo "✅ release-check passed (v$(VERSION))" + +## package-release: 产出发布二进制与 sha256 +package-release: + @mkdir -p $(DIST_DIR) + @CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o $(DIST_DIR)/$(ASSET_NAME) cmd/main.go + @sha256sum $(DIST_DIR)/$(ASSET_NAME) > $(DIST_DIR)/$(ASSET_NAME).sha256 + @echo "✅ package ready: $(DIST_DIR)/$(ASSET_NAME)" + +## tag-release: 创建并推送 git tag(vX.Y.Z) +tag-release: + @git rev-parse -q --verify refs/tags/$(RELEASE_TAG) >/dev/null && (echo "❌ tag exists: $(RELEASE_TAG)" && exit 1) || true + @git tag -a $(RELEASE_TAG) -m "release: $(RELEASE_TAG)" + @git push origin $(RELEASE_TAG) + @echo "✅ pushed tag $(RELEASE_TAG)" + +## release: 本地发布编排(check + build + docker + package) +release: release-check build docker package-release + @echo "✅ release local artifacts ready for $(RELEASE_TAG)" + @echo " next: make docker-push && make tag-release" + +## help: 显示帮助 +help: + @echo "$(APP_NAME) v$(VERSION)" + @echo "" + @echo "可用目标:" + @grep -E '^## ' Makefile | sed 's/^## / /' diff --git a/README.md b/README.md new file mode 100644 index 0000000..aca195a --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# 🛠️ Ops-Assistant + +一个支持 **Telegram Bot** 和 **QQ Bot** 的智能记账机器人,带 Web 管理后台。 + +> 当前稳定版本:`v1.1.12` + +## 🆕 v1.1.12 更新 + +- 新增渠道管理页:`/channels`(移动端优先,统一 UI) +- 新增审计页面:`/audit`(中文化) +- 新增一键生效接口:`POST /api/v1/admin/channels/:platform/apply` +- 渠道配置安全增强:`***` 脱敏占位符不会覆盖真实 secrets +- apply 失败返回增强:失败时返回 `stage` 与 `committed` 字段 +- 首页/登录/渠道/审计页面版本显示统一为 `vX.Y.Z` + +## ✨ 功能特性 + +- **多平台支持**:同时接入 Telegram Bot 和 QQ Bot(WebSocket 模式) +- **智能记账**:发送自然语言自动识别金额和消费分类(基于 jieba 分词) +- **150+ 预设分类关键词**:餐饮、交通、购物、饮品、水果、住房、医疗、娱乐等 +- **Web 管理后台**:响应式设计,支持记录查看、删除、CSV 导出 +- **金额精确**:使用分(int64)存储,避免浮点精度问题 +- **优雅关闭**:支持 SIGTERM/SIGINT 信号,干净退出 + +## 📦 快速开始 + +### 二进制运行 + +```bash +# 1. 复制配置文件 +cp config.yaml.example config.yaml + +# 2. 编辑配置(填入 Bot Token 等信息) +vi config.yaml + +# 3. 运行 +./ops-assistant +``` + +### Docker 运行 + +```bash +# 1. 准备配置文件 +cp config.yaml.example config.yaml +vi config.yaml + +# 2. 使用 docker-compose +docker-compose up -d + +# 或直接 docker run +docker run -d \ + --name ops-assistant \ + -p 9621:9621 \ + -v $(pwd)/config.yaml:/app/config.yaml:ro \ + -v $(pwd)/data:/app/data \ + ouaone/ops-assistant:latest +``` + +### 从源码编译 + +```bash +# 需要 Go 1.22+、GCC(gojieba 依赖 CGO) +make build + +# 交叉编译 Linux +make build-linux +``` + +## ⚙️ 配置说明 + +```yaml +server: + port: 9621 # Web 后台端口 + key: "your-secret-key" # 会话密钥 + +database: + path: "./xiaji.db" # SQLite 数据库路径 + +admin: + username: "admin" # Web 后台用户名 + password: "your_password" # Web 后台密码 + +telegram: + enabled: true # 是否启用 Telegram Bot + token: "YOUR_BOT_TOKEN" # BotFather 获取的 Token + +qqbot: + enabled: false # 是否启用 QQ Bot + appid: "YOUR_APPID" # QQ 开放平台 AppID + secret: "YOUR_SECRET" # QQ 开放平台 AppSecret +``` + +## 💬 使用方式 + +### Telegram Bot 命令 + +| 命令 | 说明 | +|------|------| +| `/start` | 欢迎信息 | +| `/help` | 使用帮助 | +| `/list` | 最近 10 条记录 | +| `/today` | 今日消费汇总 | +| 直接发消息 | 自动记账(如"午饭 25元") | + +### QQ Bot 关键词 + +| 关键词 | 说明 | +|--------|------| +| `帮助` / `菜单` / `功能` | 显示帮助 | +| `查看` / `记录` / `列表` | 最近 10 条记录 | +| `今日` / `今天` | 今日消费汇总 | +| 直接发消息 | 自动记账 | + +### 记账格式 + +支持多种自然语言格式: + +``` +午饭 25元 +打车 ¥30 +买咖啡15块 +车费10元 +超市买水果38.5 +``` + +### 消费分类 + +系统预设了 150+ 个关键词,自动匹配以下分类: + +| 分类 | 示例关键词 | +|------|-----------| +| 🍜 餐饮 | 早饭、午饭、晚饭、外卖、火锅、面条 | +| 🚗 交通 | 打车、车费、地铁、公交、加油 | +| 🛒 购物 | 买、淘宝、超市、衣服、手机 | +| ☕ 饮品 | 咖啡、奶茶、星巴克、可乐 | +| 🍎 水果 | 水果、苹果、香蕉、西瓜 | +| 🍪 零食 | 零食、面包、蛋糕、甜品 | +| 🏠 住房 | 房租、水电、物业、宽带 | +| 📱 通讯 | 话费、流量、充值 | +| 💊 医疗 | 看病、药、医院、挂号 | +| 🎮 娱乐 | 电影、游戏、健身、旅游 | +| 📚 教育 | 书、课、培训、学费 | +| 🚬 烟酒 | 烟、白酒、红酒 | + +## 🌐 Web 管理后台 + +访问 `http://localhost:9621`: + +- 📊 今日支出 / 本月支出 / 总记录数统计 +- 📋 最近 50 条记录列表 +- 🔍 按分类筛选 +- 🗑️ 删除记录 +- 📥 CSV 导出(Excel 兼容 BOM 编码) + +## 🏗️ 项目结构 + +``` +ops-assistant/ +├── cmd/main.go # 入口文件 +├── config/config.go # 配置加载与验证 +├── version/version.go # 版本信息 +├── models/models.go # 数据模型与分类关键词 +├── internal/ +│ ├── bot/telegram.go # Telegram Bot +│ ├── qq/qq.go # QQ Bot (WebSocket) +│ ├── service/finance.go # 记账核心服务(jieba 分词) +│ └── web/server.go # Web 管理后台 +├── templates/index.html # Web 前端页面 +├── config.yaml.example # 配置示例 +├── Dockerfile # Docker 镜像 +├── docker-compose.yml # Docker Compose +└── Makefile # 构建脚本 +``` + +## 📋 技术栈 + +- **语言**: Go 1.22 +- **Web 框架**: Gin +- **数据库**: SQLite (GORM) +- **分词**: gojieba (结巴分词 Go 版) +- **Telegram SDK**: go-telegram-bot-api v5 +- **QQ SDK**: tencent-connect/botgo v0.2.1 (WebSocket 模式) + +## 📄 License + +MIT diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..8acdd82 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.0.1 diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..6bde4b2 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "ops-assistant/config" + "ops-assistant/internal/bot" + "ops-assistant/internal/channel" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/core/runbook" + "ops-assistant/internal/feishu" + "ops-assistant/internal/qq" + "ops-assistant/internal/service" + "ops-assistant/internal/web" + "ops-assistant/models" + "ops-assistant/version" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func main() { + if len(os.Args) > 1 && (os.Args[1] == "-v" || os.Args[1] == "--version" || os.Args[1] == "version") { + fmt.Println(version.Info()) + return + } + + log.Printf("🦞 %s", version.Info()) + + cfgPath := "config.yaml" + if len(os.Args) > 1 { + cfgPath = os.Args[1] + } + + cfg, err := config.LoadConfig(cfgPath) + if err != nil { + log.Fatalf("无法加载配置: %v", err) + } + + db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{}) + if err != nil { + log.Fatalf("无法连接数据库: %v", err) + } + + if err := models.Migrate(db); err != nil { + log.Fatalf("数据库迁移失败: %v", err) + } + + if err := channel.InitSecretCipher(cfg.Server.Key); err != nil { + log.Fatalf("初始化渠道密钥加密失败: %v", err) + } + + if config.IsWeakPassword(cfg.Admin.Password) { + log.Printf("⚠️ admin 密码过弱或为默认值,请尽快修改") + } + + // DB 渠道配置覆盖 YAML 配置 + if err := channel.ApplyChannelConfig(db, cfg); err != nil { + log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err) + } + + finance := service.NewFinanceService(db) + defer finance.Close() + + if err := runbook.SeedDefaultTargets(db); err != nil { + log.Printf("⚠️ 初始化ops targets失败: %v", err) + } + + opsSvc := ops.BuildDefault(db, cfg.Database.Path, ".") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if cfg.Telegram.Enabled { + tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance, opsSvc) + if err != nil { + log.Printf("⚠️ TG Bot 启动失败: %v", err) + } else { + go tgBot.Start(ctx) + } + } + + if cfg.QQBot.Enabled { + qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance, opsSvc) + go qqBot.Start(ctx) + } + + engine := gin.New() + engine.Use(gin.Recovery()) + engine.Use(gin.Logger()) + + reloadFn := func() (string, error) { + if err := channel.ApplyChannelConfig(db, cfg); err != nil { + return "", err + } + return fmt.Sprintf("reload ok: tg=%v qq=%v feishu=%v", cfg.Telegram.Enabled, cfg.QQBot.Enabled, cfg.Feishu.Enabled), nil + } + + webServer := web.NewWebServer(db, cfg.Database.Path, ".", finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn) + webServer.RegisterRoutes(engine) + + if cfg.Feishu.Enabled { + fsBot := feishu.NewBot(db, finance, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey) + fsBot.RegisterRoutes(engine) + go fsBot.Start(ctx) + } + + go func() { + logAddr := fmt.Sprintf(":%d", cfg.Server.Port) + log.Printf("🌐 Web后台运行在 http://127.0.0.1%s", logAddr) + if err := engine.Run(logAddr); err != nil { + log.Printf("❌ Web服务启动失败: %v", err) + } + }() + + log.Println("🛠️ Ops-Assistant 已全面启动") + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + + log.Println("⏳ 正在关闭服务...") + cancel() + time.Sleep(2 * time.Second) + + sqlDB, err := db.DB() + if err == nil { + sqlDB.Close() + } + + log.Println("👋 Ops-Assistant 已关闭") +} diff --git a/cmd/ops-runner/main.go b/cmd/ops-runner/main.go new file mode 100644 index 0000000..f4b70af --- /dev/null +++ b/cmd/ops-runner/main.go @@ -0,0 +1,132 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "ops-assistant/internal/core/ops" +) + +func main() { + if len(os.Args) < 4 { + fmt.Println("usage: ops-runner ") + os.Exit(2) + } + dbPath := os.Args[1] + baseDir := os.Args[2] + cmd := os.Args[3] + + parts := strings.Fields(cmd) + if len(parts) < 2 { + fmt.Println("ERR: invalid command") + os.Exit(2) + } + + switch { + case len(parts) >= 2 && parts[0] == "/cf" && parts[1] == "dnsadd": + if len(parts) < 4 { + fmt.Println("ERR: /cf dnsadd [on|off] [type]") + os.Exit(2) + } + inputs := map[string]string{ + "name": parts[2], + "content": parts[3], + "type": "A", + "proxied": "false", + } + if len(parts) >= 5 { + switch strings.ToLower(parts[4]) { + case "on": + inputs["proxied"] = "true" + if len(parts) >= 6 { + inputs["type"] = parts[5] + } + case "off": + inputs["proxied"] = "false" + if len(parts) >= 6 { + inputs["type"] = parts[5] + } + case "true": + inputs["proxied"] = "true" + if len(parts) >= 6 { + inputs["type"] = parts[5] + } + case "false": + inputs["proxied"] = "false" + if len(parts) >= 6 { + inputs["type"] = parts[5] + } + default: + 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) + + case len(parts) >= 2 && parts[0] == "/cf" && parts[1] == "dnsproxy": + if len(parts) < 4 { + fmt.Println("ERR: /cf dnsproxy on|off") + os.Exit(2) + } + mode := strings.ToLower(parts[3]) + if mode != "on" && mode != "off" { + fmt.Println("ERR: /cf dnsproxy on|off") + os.Exit(2) + } + proxied := "false" + if mode == "on" { + proxied = "true" + } + target := parts[2] + inputs := map[string]string{ + "proxied": proxied, + "record_id": "__empty__", + "name": "__empty__", + } + if strings.Contains(target, ".") { + inputs["name"] = target + } else { + inputs["record_id"] = target + } + jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cf_dns_proxy", 1, inputs) + if err != nil { + fmt.Printf("ERR: %v\n", err) + os.Exit(1) + } + fmt.Printf("OK job=%d\n", jobID) + + case len(parts) >= 2 && parts[0] == "/cpa" && parts[1] == "status": + jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cpa_status", 1, map[string]string{}) + if err != nil { + fmt.Printf("ERR: %v\n", err) + os.Exit(1) + } + fmt.Printf("OK job=%d\n", jobID) + case len(parts) >= 3 && parts[0] == "/cpa" && parts[1] == "usage" && parts[2] == "backup": + jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cpa_usage_backup", 1, map[string]string{}) + if err != nil { + fmt.Printf("ERR: %v\n", err) + os.Exit(1) + } + fmt.Printf("OK job=%d\n", jobID) + case len(parts) >= 4 && parts[0] == "/cpa" && parts[1] == "usage" && parts[2] == "restore": + inputs := map[string]string{ + "backup_id": parts[3], + } + jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cpa_usage_restore", 1, inputs) + if err != nil { + fmt.Printf("ERR: %v\n", err) + os.Exit(1) + } + fmt.Printf("OK job=%d\n", jobID) + default: + fmt.Println("ERR: unsupported command") + os.Exit(2) + } +} diff --git a/cmd/runbook_test.go b/cmd/runbook_test.go new file mode 100644 index 0000000..ffdd098 --- /dev/null +++ b/cmd/runbook_test.go @@ -0,0 +1,66 @@ +//go:build runbooktest +// +build runbooktest + +package main + +import ( + "fmt" + "os" + "path/filepath" + + "ops-assistant/internal/core/ops" +) + +func main() { + if len(os.Args) < 4 { + fmt.Println("usage: runbook_test ") + os.Exit(2) + } + dbPath := os.Args[1] + baseDir := os.Args[2] + cmd := os.Args[3] + inputs := map[string]string{} + // minimal parse for /cf dnsadd [true] [type] + parts := split(cmd) + if len(parts) >= 4 && parts[0] == "/cf" && parts[1] == "dnsadd" { + inputs["name"] = parts[2] + inputs["content"] = parts[3] + inputs["type"] = "A" + inputs["proxied"] = "false" + if len(parts) >= 5 { + if parts[4] == "true" { + inputs["proxied"] = "true" + if len(parts) >= 6 { + inputs["type"] = parts[5] + } + } else { + inputs["type"] = parts[4] + } + } + } + jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cf_dns_add", 1, inputs) + if err != nil { + fmt.Printf("ERR: %v\n", err) + os.Exit(1) + } + fmt.Printf("OK job=%d\n", jobID) +} + +func split(s string) []string { + out := []string{} + cur := "" + for _, r := range s { + if r == ' ' || r == '\t' || r == '\n' { + if cur != "" { + out = append(out, cur) + cur = "" + } + continue + } + cur += string(r) + } + if cur != "" { + out = append(out, cur) + } + return out +} diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..313d787 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,26 @@ +server: + port: 9621 + key: "your-secret-key-change-me" + +database: + path: "./xiaji.db" + +admin: + username: "admin" + password: "your_password" + +telegram: + enabled: true + token: "YOUR_TELEGRAM_BOT_TOKEN" + +qqbot: + enabled: false + appid: "YOUR_QQ_BOT_APPID" + secret: "YOUR_QQ_BOT_SECRET" + +feishu: + enabled: false + app_id: "YOUR_FEISHU_APP_ID" + app_secret: "YOUR_FEISHU_APP_SECRET" + verification_token: "YOUR_FEISHU_VERIFICATION_TOKEN" + encrypt_key: "YOUR_FEISHU_ENCRYPT_KEY" diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..c66dabe --- /dev/null +++ b/config/config.go @@ -0,0 +1,121 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server struct { + Port int `yaml:"port"` + Key string `yaml:"key"` + } `yaml:"server"` + Database struct { + Path string `yaml:"path"` + } `yaml:"database"` + Telegram struct { + Enabled bool `yaml:"enabled"` + Token string `yaml:"token"` + } `yaml:"telegram"` + QQBot struct { + Enabled bool `yaml:"enabled"` + AppID string `yaml:"appid"` + Secret string `yaml:"secret"` + } `yaml:"qqbot"` + Feishu struct { + Enabled bool `yaml:"enabled"` + AppID string `yaml:"app_id"` + AppSecret string `yaml:"app_secret"` + VerificationToken string `yaml:"verification_token"` + EncryptKey string `yaml:"encrypt_key"` + } `yaml:"feishu"` + AI struct { + Enabled bool `yaml:"enabled"` + BaseURL string `yaml:"base_url"` + APIKey string `yaml:"api_key"` + Model string `yaml:"model"` + TimeoutSeconds int `yaml:"timeout_seconds"` + } `yaml:"ai"` + Admin struct { + Username string `yaml:"username"` + Password string `yaml:"password"` + } `yaml:"admin"` +} + +func LoadConfig(path string) (*Config, error) { + cfg := &Config{} + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("打开配置文件失败: %w", err) + } + defer file.Close() + + d := yaml.NewDecoder(file) + if err := d.Decode(&cfg); err != nil { + return nil, fmt.Errorf("解析配置文件失败: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("配置验证失败: %w", err) + } + + return cfg, nil +} + +func IsWeakPassword(pw string) bool { + p := strings.TrimSpace(pw) + if p == "" { + return true + } + weak := map[string]bool{ + "admin123": true, + "your_password": true, + "CHANGE_ME": true, + "change_me": true, + "password": true, + "123456": true, + "12345678": true, + "qwerty": true, + } + return weak[p] +} + +func (c *Config) Validate() error { + if c.Database.Path == "" { + return fmt.Errorf("database.path 不能为空") + } + if c.Server.Port <= 0 || c.Server.Port > 65535 { + return fmt.Errorf("server.port 必须在 1-65535 之间,当前值: %d", c.Server.Port) + } + if c.Server.Key == "" { + return fmt.Errorf("server.key 不能为空(用于会话签名)") + } + if c.Admin.Username == "" || c.Admin.Password == "" { + return fmt.Errorf("admin.username 和 admin.password 不能为空") + } + if c.Telegram.Enabled && c.Telegram.Token == "" { + return fmt.Errorf("telegram 已启用但 token 为空") + } + if c.QQBot.Enabled { + if c.QQBot.AppID == "" || c.QQBot.Secret == "" { + return fmt.Errorf("qqbot 已启用但 appid 或 secret 为空") + } + } + if c.Feishu.Enabled { + if c.Feishu.AppID == "" || c.Feishu.AppSecret == "" { + return fmt.Errorf("feishu 已启用但 app_id 或 app_secret 为空") + } + } + if c.AI.Enabled { + if c.AI.BaseURL == "" || c.AI.APIKey == "" || c.AI.Model == "" { + return fmt.Errorf("ai 已启用但 base_url/api_key/model 为空") + } + if c.AI.TimeoutSeconds <= 0 { + c.AI.TimeoutSeconds = 15 + } + } + return nil +} diff --git a/dist/ops-assistant-v0.0.1-linux-amd64 b/dist/ops-assistant-v0.0.1-linux-amd64 new file mode 100755 index 0000000..dd4b40c Binary files /dev/null and b/dist/ops-assistant-v0.0.1-linux-amd64 differ diff --git a/dist/ops-assistant-v0.0.1-linux-amd64.sha256 b/dist/ops-assistant-v0.0.1-linux-amd64.sha256 new file mode 100644 index 0000000..2b3797f --- /dev/null +++ b/dist/ops-assistant-v0.0.1-linux-amd64.sha256 @@ -0,0 +1 @@ +55bfe12944a42957532b9f63492d9ed8ca600419c4352ffa35344a62598bc019 dist/ops-assistant-v0.0.1-linux-amd64 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d0fc616 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' + +services: + ops-assistant: + image: ouaone/ops-assistant:latest + container_name: ops-assistant + restart: unless-stopped + ports: + - "9621:9621" + volumes: + - ./config.yaml:/app/config.yaml:ro + - ./data:/app/data + environment: + - TZ=Asia/Shanghai diff --git a/docs/API_RESPONSE_CONVENTION.md b/docs/API_RESPONSE_CONVENTION.md new file mode 100644 index 0000000..7d65396 --- /dev/null +++ b/docs/API_RESPONSE_CONVENTION.md @@ -0,0 +1,36 @@ +# API Response Convention + +统一响应格式(JSON 接口): + +- 成功: +```json +{ + "code": "OK", + "message": "ok|success|...", + "data": { } +} +``` + +- 失败: +```json +{ + "code": "ERR_...", + "message": "..." +} +``` + +## 约定 + +1. 所有 `/api/v1/*` JSON 接口使用统一结构。 +2. 文件流接口(如 CSV 导出)成功时返回文件内容;失败时返回统一错误 JSON。 +3. 权限失败统一 `ERR_PERMISSION_DENIED`。 +4. 执行失败统一至少返回一个业务错误码(例如 `ERR_STEP_FAILED`)。 +5. `message` 用于人类可读,`code` 用于前端逻辑分支。 + +## 已接入范围(2026-03-10) + +- Ops: jobs/list/detail/cancel/retry +- Flags: list/patch +- Channels: list/patch/publish/reload/enable/disable/disable-all/test/apply +- Records: list/delete +- Audit: list diff --git a/docs/ai_command_guide.md b/docs/ai_command_guide.md new file mode 100644 index 0000000..c61d1a8 --- /dev/null +++ b/docs/ai_command_guide.md @@ -0,0 +1,150 @@ +# AI 命令解释知识库(用于自然语言→标准命令翻译) + +## 总原则 +- 目标:把用户的自然语言翻译成**唯一**的标准命令。 +- 只输出一行命令,不要解释。 +- 不能确定时输出 `FAIL`。 +- 标准命令必须以 `/` 开头。 + +--- + +## OPS 模块 +### /help +**用途**:显示 OPS 可用命令清单(同 /start /ops help)。 +**常见自然语**: +- 你能做什么 / 你会什么 +- 功能 / 菜单 / 帮助 / help +- 查看命令 / 看下命令 +**输出命令**: +- `/help` + +### /ops modules +**用途**:查看模块启用状态。 +**常见自然语**: +- 模块状态 / 查看模块 +- ops 模块 / ops modules +**输出命令**: +- `/ops modules` + +--- + +## CPA 模块 +### /cpa help +**用途**:显示 CPA 模块可用命令。 +**常见自然语**: +- cpa 帮助 / cpa help +**输出命令**: +- `/cpa help` + +**用途**:查询 CPA 服务状态与 usage 快照。 +**常见自然语**: +- cpa状态 / CPA状态 / cpa status / cpastatus +- cpa 服务状态 / cpa 运行状态 / cpa 正常吗 +- 查下 cpa / 看下 cpa 状态 +**输出命令**: +- `/cpa status` + +### /cpa usage backup +**用途**:导出实时 usage 并打包备份。 +**常见自然语**: +- cpa 备份 / 备份 cpa +- 备份 usage / cpa usage 备份 +- 导出 cpa 使用情况 +**输出命令**: +- `/cpa usage backup` + +### /cpa usage restore +**用途**:从备份恢复 usage。 +**常见自然语**: +- 恢复 cpa / 回滚 cpa +- 用备份 恢复 +- 恢复 usage +**输出命令**: +- `/cpa usage restore ` + +--- + +## Cloudflare 模块 +### /cf status +**用途**:查看 Cloudflare 配置状态。 +**常见自然语**: +- cf 状态 / cloudflare 状态 +- cf 配置怎么样 +**输出命令**: +- `/cf status` + +### /cf zones +**用途**:列出 zone 列表。 +**常见自然语**: +- cf zones / cf 区域列表 / 站点列表 +**输出命令**: +- `/cf zones` + +### /cf dns list +**用途**:列出指定 zone 的 DNS 记录。 +**常见自然语**: +- 列出 DNS / 查看 DNS / cf dns list +- zone 的 dns 记录 +**输出命令**: +- `/cf dns list ` + +### /cf dns update [ttl] [proxied] +**用途**:更新 DNS 记录。 +**常见自然语**: +- 更新 dns / 修改解析 +- 把 改成 +**输出命令**: +- `/cf dns update [ttl] [proxied]` + +### /cf dnsadd [true] [type] +**用途**:新增 DNS 记录(name 自动匹配 zone)。 +**常见自然语**: +- 新增解析 / 添加 DNS +- 增加 -> +**输出命令**: +- `/cf dnsadd [true] [type]` + +### /cf dnsset [true] +**用途**:按 record_id 修改内容(可选开启代理)。 +**常见自然语**: +- 更新记录内容 / 改 IP +**输出命令**: +- `/cf dnsset [true]` + +### /cf dnsdel YES +**用途**:删除 DNS 记录(需 YES 确认)。 +**常见自然语**: +- 删除 DNS 记录 / 删解析 +**输出命令**: +- `/cf dnsdel YES` + +### /cf dnsproxy on|off +**用途**:切换代理开关(不改 content)。 +**常见自然语**: +- 开启/关闭代理 +**输出命令**: +- `/cf dnsproxy on|off` + +### /cf workers list +**用途**:列出 Workers。 +**常见自然语**: +- 列出 workers / cf workers +**输出命令**: +- `/cf workers list` + +--- + +## Mail 模块 +### /mail status +**用途**:查看邮件服务/配置状态。 +**常见自然语**: +- 邮件状态 / mail 状态 +- 邮箱配置是否正常 +**输出命令**: +- `/mail status` + +--- + +## 兜底规则 +- 无法确定命令或缺少关键参数时输出 `FAIL`。 +- 不要猜测 zone_id/record_id/backup_id 等关键参数。 diff --git a/docs/architecture-core.md b/docs/architecture-core.md new file mode 100644 index 0000000..a93e15f --- /dev/null +++ b/docs/architecture-core.md @@ -0,0 +1,104 @@ +# Ops-Assistant Core Architecture (v0.0.1) + +> 目标:程序可独立运行,AI 可选接入但无执行权;新通道/新功能模块可低成本适配接入。 + +## 1. 设计红线(强约束) + +1. **执行权唯一入口**:所有变更类动作只能经 `Command -> Policy -> Runbook Engine`。 +2. **AI 不可执行**:AI 仅能做建议/解释,不可直接调用执行器。 +3. **通道与业务解耦**:TG/QQ/Feishu 仅适配输入输出,不承载业务规则。 +4. **模块与核心解耦**:CPA/CF/Mail 仅注册命令和构建 runbook 请求。 +5. **全链路可审计**:job/step、operator、request_id、risk_level、input_json 必须可追溯。 + +--- + +## 2. 分层结构 + +1) **Channel Adapter 层** +- 责任:收消息、身份映射、回消息。 +- 输入:平台消息。 +- 输出:统一消息结构(text/operator/channel)。 + +2) **Command Runtime 层** +- 责任:命令解析、路由、参数校验。 +- 输出:模块请求(Module Request)。 + +3) **Policy Gate 层** +- 责任:权限、feature flag、confirm token、dry-run。 +- 输出:允许/拒绝(含原因)。 + +4) **Runbook Engine 层** +- 责任:确定性执行 step(白名单动作)、锁、超时、断言、取消。 + +5) **Audit/State 层** +- 责任:记录 job/step 状态和执行证据。 + +6) **AI Advisor(可选)** +- 责任:建议命令、参数补全、解释结果。 +- 限制:**无执行器句柄、无 DB 写权限、无外部动作权限**。 + +--- + +## 3. 接入标准 + +### 3.1 新通道接入(最小接口) +- 实现统一消息输入 +- 实现统一回复输出 +- 提供身份映射(operator_id) + +> 新通道不允许直接操作 runbook / db。 + +### 3.2 新功能模块接入(最小接口) +- 注册命令前缀(如 `/cpa` `/cf` `/mail`) +- 输入校验 +- 构建 runbook 请求(name + inputs + meta + policy) + +> 新模块不允许直接执行 shell/ssh,只能请求 runbook 引擎。 + +### 3.3 当前注册机制 +- Parser 按 `/xxx` 自动识别 `module=xxx` +- Registry 支持两种注册: + - 精确命令注册(`Register("/health", ...)`) + - 模块前缀注册(`RegisterModule("cpa", ...)`) +- 推荐新模块使用 `RegisterModule`,降低接入成本。 + +--- + +## 4. AI 介入模式 + +- `off`:完全关闭 AI(默认可用) +- `suggest`:允许 AI 给出命令建议,不自动执行 +- `explain`:允许 AI 解释结果,不自动执行 + +### 安全边界 +- AI 输出必须是文本建议,不是可执行指令。 +- 任何执行动作都需要用户命令触发并通过 Policy Gate。 + +--- + +## 5. 统一执行链 + +`Channel -> Command -> Module.BuildRequest -> Policy.Check -> Runbook.Execute -> Audit -> Reply` + +这条链是扩展 CF/Mail 时的唯一合法路径。 + +--- + +## 6. 当前状态与后续 + +### 已具备 +- Runbook 引擎(执行/锁/超时/取消) +- CPA 模块(status/backup/restore) +- 任务审计(jobs/steps) +- CF/Mail 模块骨架(`/cf status`、`/mail status` 占位) + +### 下一步(框架整理) +1. 将模块内零散 gate 逻辑统一沉淀到 `core/policy` +2. 形成通道适配模板和模块适配模板 +3. 引入 `core/ai` 的 Noop Advisor(默认 off) +4. 在 CF/Mail 骨架上补齐真实 runbook(不改核心) + +### 模块管理可观测性 +- 聊天命令:`/ops modules` 查看注册模块与启用状态 +- Web 首页:模块状态卡片(读取 `enable_module_*`) +- 模块启停:统一通过 feature flag 管理,不改代码路径 diff --git a/docs/backend-independence-status.md b/docs/backend-independence-status.md new file mode 100644 index 0000000..7cd0a21 --- /dev/null +++ b/docs/backend-independence-status.md @@ -0,0 +1,89 @@ +# Ops-Assistant 后端独立化完成清单(Phase 1~6) + +更新日期:2026-03-10 + +--- + +## 总览 + +目标:`ops-assistant` 后端从旧 `xiaji-go` 记账心智中独立,形成 Ops 场景可扩展架构。 + +当前结论: +- ✅ 核心链路已成型:`Command -> Module -> Policy -> Runbook -> Audit -> Reply` +- ✅ 三模块(CPA/CF/Mail)已纳入统一执行骨架(CF/Mail 为安全占位 runbook) +- ✅ Ops Web 数据面已具备(dashboard/modules/jobs/channels/audit/flags) +- ✅ Legacy 兼容层已进入可观测可下线阶段(usage/trend/readiness) + +--- + +## Phase 状态 + +## Phase 1:API 基础收口 +- ✅ 新增/改造接口统一 `{code,message,data}` +- ✅ 导出类接口保留文件流(`/api/v1/export`) +- ✅ 失败路径统一 JSON 错误 + +## Phase 2:模块管理后端 +- ✅ `GET /api/v1/modules` +- ✅ `POST /api/v1/modules/:module/toggle` +- ✅ toggle reason 必填 +- ✅ toggle 幂等 noop 返回 +- ✅ 关键模块保护:禁止禁用 `cpa` + +## Phase 3:Dashboard 聚合后端 +- ✅ `GET /api/v1/dashboard/overview` +- ✅ `GET /api/v1/dashboard/summary` +- ✅ jobs 列表多维筛选(见 Phase 4) + +## Phase 4:Ops Jobs 能力增强 +- ✅ `GET /api/v1/ops/jobs` 支持筛选: + - `limit/status/target/runbook/request_id/operator/risk_level/q/from/to` +- ✅ `GET /api/v1/ops/jobs/request/:requestID`(含 `total`) +- ✅ `GET /api/v1/ops/jobs/:id`(`step_stats/step_total/duration`) +- ✅ `POST /api/v1/ops/jobs/:id/cancel`(reason 必填,状态校验) +- ✅ `POST /api/v1/ops/jobs/:id/retry`(reason 必填,仅 failed) + +## Phase 5:策略与风控中心化(阶段性) +- ✅ `policy.CheckGate` 作为 gate 核心校验 +- ✅ 新增 `policy.ParseCommonFlags`,统一 `--dry-run/--confirm` +- ✅ CPA 移除重复 guard(不再分散解析) +- ✅ CF/Mail 切到 Runner + Gate + Runbook 模式 +- ⏳ 待补:更细粒度 permission/gate 模板化(跨模块统一声明) + +## Phase 6:兼容层收缩与下线准备 +- ✅ legacy 路由包装审计: + - `/api/records` + - `/delete/:id` + - `/export` +- ✅ 访问 legacy 自动写审计:`legacy.route.access` +- ✅ legacy deprecated 响应头: + - `X-API-Deprecated: true` + - `X-API-Replacement` + - `Warning: 299 ...` +- ✅ 迁移观测接口: + - `GET /api/v1/admin/legacy/usage` + - `GET /api/v1/admin/legacy/trend?days=...` + - `GET /api/v1/admin/legacy/readiness?days=...&zero_days=...` + +--- + +## 仍在进行 / 建议下一步 + +1) Policy 进一步模板化 +- 将 `NeedFlag/RequireConfirm/AllowDryRun` 配置抽成模块命令描述,减少模块内手写。 + +2) Channel Adapter 统一化 +- TG/QQ/Feishu 进一步收敛到 `core/ports/channel.go` 统一适配层。 + +3) Legacy 软下线流程 +- 当 `readiness.ready=true` 连续多日后,先灰度关闭 legacy,再完全移除路由映射。 + +4) 发布前整理 +- 清理工作区改动并提交;执行 `make release-check`。 + +--- + +## 关键验证 + +- 最近多轮 `go build ./cmd/main.go` 均通过(仅 gojieba C++ deprecation warning,非阻塞)。 +- 双服务并存策略维持不变:`xiaji-go` 与 `ops-assistant` 并行。 diff --git a/docs/cf-commands-design.md b/docs/cf-commands-design.md new file mode 100644 index 0000000..0b001ca --- /dev/null +++ b/docs/cf-commands-design.md @@ -0,0 +1,123 @@ +# CF 命令设计(ops-assistant v1) + +## 目标 +为 ops-assistant 提供 Cloudflare DNS 管理能力,保证幂等、安全、可审计。 + +--- + +## 1. 命令清单 + +- `/cf zones` + - 列出可用 Zone(name/id) + +- `/cf dnslist [name] [type]` + - 示例:`/cf dnslist example.com` + - 示例:`/cf dnslist example.com www A` + +- `/cf dnsadd [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]` + - 示例:`/cf dnsadd test.fnos.xx.kg 1.2.3.4` + +- `/cf dnsset [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]` + - 存在则更新,不存在则创建 + +- `/cf dnsdel --type ` + - **必须带 `--type`** + - **需二次确认 `YES`** + +- `/cf dnsproxy on|off` + - 仅更新 proxied + +- `/cf health` + - Token/权限可用性检查 + +--- + +## 2. 权限与安全 + +- `read` 角色:仅允许 `zones / dnslist / health` +- `admin` 角色:允许所有命令 +- `dnsdel` 必须二次确认 `YES` + +--- + +## 3. 参数校验 + +- `fqdn` 必须属于某个 Zone(`.`) +- `ip` 校验: + - `A` → IPv4 + - `AAAA` → IPv6 +- `type` 默认 `A` +- `ttl` 默认 `120` +- `proxied` 默认保持原值(仅 dnsproxy/dnsset 明确修改) + +--- + +## 4. 幂等与更新规则 + +### `/cf dnsadd` +1) 查询同名同类型记录 +2) 存在 → 返回“已存在” +3) 不存在 → 创建 + +### `/cf dnsset` +- 若任一字段不同则更新: + - `content` + - `ttl` + - `proxied` +- content 相同但 ttl/proxied 不同 → 仍执行更新 + +### `/cf dnsdel` +- 必须带 `--type` +- 仅删除指定 type 记录 + +--- + +## 5. 输出规范 + +### 成功 +``` +✅ DNS操作成功 +- name: +- type: +- content: +- ttl: +- proxied: +- id: +``` + +### 已存在 +``` +ℹ️ DNS记录已存在(未变更) +- name: +- type: +- content: +- id: +``` + +### 失败 +``` +❌ DNS操作失败 +- reason: <鉴权/参数/CF错误> +- detail: +``` + +--- + +## 6. 审计日志(必写) + +字段: +- `ts / actor / cmd / zone / fqdn / action / record_id / result / detail` + +示例: +```json +{ + "ts": "2026-03-14T15:02:10+08:00", + "actor": "kory", + "cmd": "/cf dnsadd test.fnos.xx.kg 1.2.3.4", + "zone": "fnos.xx.kg", + "record_id": "xxxxx", + "action": "create", + "result": "ok", + "detail": {"type":"A","ttl":120,"proxied":false} +} +``` diff --git a/docs/cf-dns-runbook-incident-20260313.md b/docs/cf-dns-runbook-incident-20260313.md new file mode 100644 index 0000000..bd6c6f8 --- /dev/null +++ b/docs/cf-dns-runbook-incident-20260313.md @@ -0,0 +1,39 @@ +# CF DNS Runbook 事故复盘(2026-03-13) + +## 背景 +在 ops-assistant 中新增 CF 模块命令(/cf dnsadd/dnsset/dnsdel/dnsproxy)并进行自测时,出现多次失败与反复修复,导致进度延误。 + +## 现象 +- `/cf dnsadd test.fnos.xx.kg 127.0.0.1` 连续失败多次。 +- Cloudflare API 返回 `Invalid request headers`。 +- runbook 步骤日志只回显脚本文本,未见真实 API 返回。 + +## 根因 +1) **认证方式误判** +- 将 API Token 误当作 API Key,使用了 `X-Auth-Email/X-Auth-Key` 方式,导致 CF 返回 `Invalid request headers`。 +- 修复后改回 Bearer 才验证 token 可用。 + +2) **runbook 执行方式不稳定(ssh heredoc/转义)** +- runbook 使用 `ssh.exec` 远端执行,命令通过 `bash -lc` + `ssh "..."` 传递。 +- heredoc 在多层转义/引用中被破坏,脚本未执行或被 shell 解释掉,stderr 只回显脚本内容。 +- `python3 -c` 内联也因换行/转义导致 `SyntaxError`。 + +## 卡点分析 +- **卡点本质不是 Token**,而是 **远端执行链路中的脚本传递/转义失效**。 +- 缺少一条稳定的“文本脚本传递”方式,导致同一命令多次失败、定位耗时。 + +## 最终解决 +- 改为 **base64 + python3 -c exec(...)** 的方式传递脚本,避免多层引用与换行问题。 +- `/cf dnsadd test.fnos.xx.kg 127.0.0.1` 成功创建记录。 + - record id: `acd49e048bc74c1b16d935b69f5aac54` + - job: `27` + +## 经验与改进 +1) **认证方式先验确认**:明确 Token vs Key 的调用差异。 +2) **避免 heredoc 跨 ssh**:优先 `base64|python3` 或 `python3 -c` 单行命令。 +3) **失败输出必须可见**:stderr/stdout 要确保能返回实际 API 响应,禁止“只回显脚本文本”。 +4) **阶段性汇报必须带证据**:无日志/产物不使用“完成/已执行”。 + +## 待办 +- 将 dnsset/dnsdel/dnsproxy 全部切换为稳定执行方式(base64 + python3 -c)。 +- 评估是否引入 `local.exec`,避免本地项目走 ssh 远端执行。 diff --git a/docs/channel-adapter-template.md b/docs/channel-adapter-template.md new file mode 100644 index 0000000..471d9e9 --- /dev/null +++ b/docs/channel-adapter-template.md @@ -0,0 +1,28 @@ +# Channel Adapter Template + +目标:新通道只做 I/O 适配,不耦合业务执行。 + +## 责任边界 + +- 接收平台消息并标准化为统一结构 +- 调用 `ops.Service.Handle(...)` 处理命令 +- 将文本回复回传到平台 + +## 禁止事项 + +- 不在通道内直接执行 shell/ssh/http 运维动作 +- 不在通道内做模块业务判断(应交给 command/module) + +## 最小流程 + +1. `Normalize(raw)` -> `UnifiedMessage` +2. `handled, out := opsSvc.Handle(msg.OperatorID, msg.Text)` +3. `if handled { Reply(out) }` +4. 未处理消息再回退到历史业务逻辑(如记账) + +## 接入检查 + +- [ ] 仅实现消息适配与回复 +- [ ] 已接入 opsSvc.Handle +- [ ] 未绕过 policy/runbook +- [ ] 错误信息对用户可读 diff --git a/docs/debug/cf-dnsproxy-dnsadd-20260319.md b/docs/debug/cf-dnsproxy-dnsadd-20260319.md new file mode 100644 index 0000000..c9234fe --- /dev/null +++ b/docs/debug/cf-dnsproxy-dnsadd-20260319.md @@ -0,0 +1,79 @@ +# CF DNS 命令修复与扩展记录(2026-03-19) + +## 背景 +用户要求: +- `/cf dnsproxy` 支持直接用域名,例如:`/cf dnsproxy ima.good.xx.kg on` +- `/cf dnsadd` 最后参数用 `on/off` 表示是否开启代理 + +线上报错: +- `yaml: line 8: did not find expected key` +- `/cf dnsproxy` 解析失败(bash: bad substitution) + +## 改动概览 +1) **命令解析** +- `internal/module/cf/commands.go` + - `/cf dnsproxy` 支持 `record_id|name` + - `/cf dnsadd` 支持 `on/off`(兼容 true/false;当未提供 on/off 时把第4参数视为类型) + +2) **帮助文案** +- `internal/module/cf/module.go` +- `internal/core/ops/service.go` + - 更新 `/cf dnsadd` 与 `/cf dnsproxy` 的参数示例 + +3) **runbook 修复** +- `runbooks/cf_dns_proxy.yaml` + - 解决 YAML 行内命令渲染与变量替换问题 + - 修复 `${env.INPUT_RECORD_ID}` 未替换导致 bash 报错 + - 加入占位值 `__empty__`,避免空变量导致替换缺失 + - `update_dns` 中 JSON 通过单引号包裹,避免 shell 分词/换行破坏 + +4) **ops-runner 支持** +- `cmd/ops-runner/main.go` + - 增加 `/cf dnsproxy` 支持 + - `/cf dnsadd` 参数改为 on/off + +## 问题与修复记录 +### 1. YAML 解析错误 +- 现象:`yaml: line 8: did not find expected key` +- 原因:runbook 中 command 复杂引号/换行组合导致 YAML 解析失败 +- 修复:重写 `cf_dns_proxy.yaml` command 区块 + +### 2. dnsproxy 变量替换失败 +- 现象:`bash: ${env.INPUT_RECORD_ID}: bad substitution` +- 原因:输入为空时,没有替换占位,shell 直接解析 `${env.INPUT_RECORD_ID}` +- 修复:InputsFn 总是注入 `record_id/name` 占位值,runbook 将 `__empty__` 转为空 + +### 3. dnsproxy update 失败(JSON 被 shell 吞掉) +- 现象:`bash: line 1: true,: command not found` +- 原因:`${steps.resolve_dns.output}` 未加引号,JSON 被 shell 拆分 +- 修复:`INPUT_JSON='${steps.resolve_dns.output}'` + +### 4. dnsadd on/off 支持 +- 现象:`DNS record type "on" is invalid` +- 原因:解析逻辑未识别 on/off,误当作类型 +- 修复:InputsFn 与 ops-runner 同步支持 `on/off` + +### 5. 测试记录创建失败(127.0.0.1) +- 现象:`Target 127.0.0.1 is not allowed for a proxied record` +- 处理:改用公网 IP 199.188.198.12 + +## 测试结果 +1) 新增测试记录 +``` +/cf dnsadd test001.good.xx.kg 199.188.198.12 on +``` +- 成功创建,proxied=true + +2) 代理切换 +``` +/cf dnsproxy ima.good.xx.kg on +``` +- 成功更新,proxied=true + +## 产物 +- 修复代码与 runbook +- 版本化二进制输出(dist/ 目录) + +## 注意事项 +- proxied=on 不能指向 127.0.0.1 等内网回环地址 +- runbook command 中 JSON 建议统一使用单引号包裹 diff --git a/docs/frontend-api-handoff.md b/docs/frontend-api-handoff.md new file mode 100644 index 0000000..e3618d0 --- /dev/null +++ b/docs/frontend-api-handoff.md @@ -0,0 +1,491 @@ +# Ops-Assistant 前端对接文档(给 Gemini) + +> 目的:让前端可独立完成 ops-assistant 新后台页面,不依赖旧 xiaji 记账页面心智。 +> 基址:同源(已登录后直接请求 `/api/v1/*`) +> 鉴权:Cookie Session(`ops_user` + `ops_token`) + +--- + +## 0. 统一响应约定(必须) + +### 成功 +```json +{ + "code": "OK", + "message": "ok", + "data": { ... } +} +``` + +### 失败 +```json +{ + "code": "ERR_XXX", + "message": "可读错误信息" +} +``` + +### 前端统一处理建议 +- `if (!res.ok)`: 显示 `message` +- `if (res.ok)`: 使用 `data` +- 不再使用 `error/status` 老字段 + +> 例外:`GET /api/v1/export` 成功是文件流下载,不是 JSON。 + +--- + +## 1. 权限与用户信息 + +### 1.1 获取当前用户 +**GET** `/api/v1/me` + +### data 字段 +- `username` +- `role` +- `user_id` +- `permissions[]` +- `flags{}` +- `effective_capabilities{}` + +### capability 常用键 +- `can_view_ops` +- `can_cancel_ops` +- `can_retry_ops` +- `can_view_flags` +- `can_edit_flags` +- `can_view_channels` +- `can_edit_channels` +- `can_test_channels` +- `can_view_audit` + +前端应以 capability 驱动按钮显隐。 + +--- + +## 2. Dashboard(新后台首页主数据) + +### 2.1 获取总览 +**GET** `/api/v1/dashboard/overview` + +### 返回 data +```json +{ + "jobs": { + "recent": [OpsJob...], + "status_count": { + "pending": 0, + "running": 0, + "success": 0, + "failed": 0, + "cancelled": 0 + } + }, + "modules": [ + {"module":"cpa","enabled":true}, + {"module":"cf","enabled":false}, + {"module":"mail","enabled":false} + ], + "channels": [ + {"platform":"telegram","enabled":true,"status":"ok"} + ] +} +``` + +### 2.2 获取轻量摘要(推荐首页首屏) +**GET** `/api/v1/dashboard/summary` + +### 返回 data +```json +{ + "jobs": { + "total": 120, + "running": 2, + "failed": 5, + "success": 98 + }, + "modules": { + "cpa": true, + "cf": false, + "mail": false + } +} +``` + +--- + +## 3. 模块管理(Module Center) + +### 3.1 模块列表 +**GET** `/api/v1/modules` + +### 返回 data +```json +{ + "modules": [ + { + "module":"cpa", + "display_name":"CPA 管理", + "flag_key":"enable_module_cpa", + "enabled":true + } + ] +} +``` + +### 3.2 切换模块开关 +**POST** `/api/v1/modules/:module/toggle` + +- `:module` 仅支持:`cpa|cf|mail` + +### body +```json +{ + "enabled": true, + "reason": "前端联调启用" +} +``` + +### 成功 data +```json +{ + "module": "cf", + "flag_key": "enable_module_cf", + "old": false, + "new": true +} +``` + +### 交互建议 +- 操作前确认弹窗 +- 必填 reason +- 成功后刷新 `/api/v1/modules` 与 `/api/v1/dashboard/overview` + +--- + +## 4. Ops 任务中心 + +### 4.1 任务列表 +**GET** `/api/v1/ops/jobs?limit=50` + +支持筛选 query: +- `status`(pending|running|success|failed|cancelled) +- `target`(如 hwsg) +- `runbook`(如 cpa_usage_backup) +- `request_id` +- `operator`(操作者 user_id,int64) +- `risk_level`(low|medium|high 等) +- `q`(模糊检索,命中 command/runbook/target/request_id) +- `from`(RFC3339,按 created_at 下界过滤) +- `to`(RFC3339,按 created_at 上界过滤) + +### data +```json +{ + "jobs": [OpsJob...], + "filters": { + "limit": 50, + "status": "failed", + "target": "hwsg", + "runbook": "cpa_usage_restore", + "request_id": "ops-u1-1741563000", + "operator": "1", + "risk_level": "high", + "q": "restore", + "from": "2026-03-10T07:00:00+08:00", + "to": "2026-03-10T08:00:00+08:00" + } +} +``` + +### 4.2 按 request_id 反查任务 +**GET** `/api/v1/ops/jobs/request/:requestID?limit=50` + +### data +```json +{ + "request_id":"req-20260310-abc", + "total": 3, + "jobs":[OpsJob...] +} +``` + +### 4.3 任务详情(含步骤 + 聚合统计) +**GET** `/api/v1/ops/jobs/:id` + +### data +```json +{ + "job": { ... }, + "steps": [ + { + "step_id":"...", + "action":"ssh.exec", + "status":"success|failed|running", + "stdout_tail":"...", + "stderr_tail":"..." + } + ], + "step_stats": { + "running": 0, + "success": 3, + "failed": 1, + "skipped": 0 + }, + "step_total": 4, + "duration": { + "job_ms": 5321, + "steps_ms_sum": 4870 + } +} +``` + +### 4.4 取消任务 +**POST** `/api/v1/ops/jobs/:id/cancel` + +body: +```json +{"reason":"人工终止,参数配置错误"} +``` + +### data +```json +{"id":123,"reason":"人工终止,参数配置错误"} +``` + +### 4.5 重试任务 +**POST** `/api/v1/ops/jobs/:id/retry` + +body: +```json +{"reason":"修复配置后重试"} +``` + +### data +```json +{"old_job_id":123,"new_job_id":456,"reason":"修复配置后重试"} +``` + +### 前端权限 +- cancel 按钮:`can_cancel_ops` +- retry 按钮:`can_retry_ops` + +### 交互约束 +- cancel/retry 必须填写 reason(后端强校验) +- retry 仅允许 failed 状态任务 +- cancel 仅允许 pending/running 状态任务 +- `from/to` 参数必须是 RFC3339(如 `2026-03-10T07:00:00+08:00`) + +--- + +## 5. 通道管理(Channels) + +### 5.1 通道列表 +**GET** `/api/v1/admin/channels` + +### data +```json +{ + "channels": [ + { + "platform":"telegram", + "name":"Telegram Bot", + "enabled": true, + "status":"ok", + "config_json":"{}", + "draft_config_json":"{}", + "secrets":"{\"token\":\"***\"}", + "draft_secrets":"{}", + "has_draft": false + } + ] +} +``` + +### 5.2 更新草稿 +**PATCH** `/api/v1/admin/channels/:platform` + +body 可包含: +- `name` +- `enabled` +- `config` +- `secrets` + +### 5.3 发布草稿 +**POST** `/api/v1/admin/channels/:platform/publish` + +### 5.4 热加载 +**POST** `/api/v1/admin/channels/reload` + +### 5.5 一键全禁用 +**POST** `/api/v1/admin/channels/disable-all` + +### 5.6 启用/禁用单通道 +- **POST** `/api/v1/admin/channels/:platform/enable` +- **POST** `/api/v1/admin/channels/:platform/disable` + +### 5.7 连通性测试 +**POST** `/api/v1/admin/channels/:platform/test` + +### 5.8 原子应用(patch + publish + reload) +**POST** `/api/v1/admin/channels/:platform/apply` + +> apply 失败 message 中可能包含 stage 信息(patch/publish/reload)。 + +--- + +## 6. 审计日志 + +### 6.1 查询审计 +**GET** `/api/v1/admin/audit` + +支持 query: +- `action` +- `target_type` +- `result` +- `actor_id` +- `from` (RFC3339) +- `to` (RFC3339) +- `limit` (默认100,最大500) +- `offset` + +### data +```json +{ "logs": [AuditLog...] } +``` + +--- + +## 7. 风险开关(Flags) + +### 7.1 列表 +**GET** `/api/v1/admin/settings/flags` + +### data +```json +{ "flags": [FeatureFlag...] } +``` + +### 7.2 修改 +**PATCH** `/api/v1/admin/settings/flags/:key` + +body: +```json +{ "enabled": true, "reason": "..." } +``` + +> 部分 flag `RequireReason=true`,reason 为空会失败。 + +--- + +## 8. 兼容层(前端新代码不要用) + +以下仅兼容旧页面: +- `GET /api/records` +- `POST /delete/:id` +- `GET /export` + +新前端一律使用 `/api/v1/*`。 + +### 8.1 Legacy 使用统计(用于迁移观察) +**GET** `/api/v1/admin/legacy/usage` + +### data +```json +{ + "summary": [ + {"route":"/api/records","count":12}, + {"route":"/delete/:id","count":3}, + {"route":"/export","count":1} + ], + "recent": [AuditLog...] +} +``` + +### 8.2 Legacy 调用趋势(按天) +**GET** `/api/v1/admin/legacy/trend?days=7` + +- `days` 范围:`1~90`,默认 `7` + +### data +```json +{ + "days": 7, + "from": "2026-03-04T00:00:00+08:00", + "trends": [ + { + "route": "/api/records", + "points": [ + {"day":"2026-03-04","count":2}, + {"day":"2026-03-05","count":1} + ] + } + ] +} +``` + +### 8.3 Legacy 响应头(便于前端观察迁移) +访问旧路由时,响应头会包含: +- `X-API-Deprecated: true` +- `X-API-Replacement: <对应新路由>` +- `Warning: 299 - "legacy API deprecated, use ..."` + +### 8.4 Legacy 下线就绪评估 +**GET** `/api/v1/admin/legacy/readiness?days=7&zero_days=3` + +- `days`:观察窗口(1~90,默认 7) +- `zero_days`:要求连续 0 调用天数(1~30,默认 3) + +### data +```json +{ + "days": 7, + "zero_days": 3, + "window_total": 0, + "route_totals": { + "/api/records": 0, + "/delete/:id": 0, + "/export": 0 + }, + "consecutive_zero_days": { + "/api/records": 7, + "/delete/:id": 7, + "/export": 7 + }, + "ready": true, + "recommendation": "可考虑下线 legacy 路由(已满足连续0调用阈值)" +} +``` + +--- + +## 9. 前端落地建议(给 Gemini) + +1. 建立统一 `apiClient`: + - `request(url, options)` + - 自动解析 `{code,message,data}` + - 非 2xx 抛 message + +2. 页面优先级: + - P1:Dashboard(overview + summary) + - P1:Ops Jobs + - P1:Modules + - P2:Channels + - P2:Audit + +3. 权限驱动 UI: + - 所有按钮显隐由 `effective_capabilities` 控制 + +4. 状态刷新策略: + - dashboard 每 10~20s 轮询 + - ops jobs running 状态 5~10s 轮询 + +5. 错误展示: + - toast + message 文本 + - 不展示后端堆栈 + +--- + +## 10. 已知限制 + +- export 成功仍为文件流(设计如此)。 +- gojieba 编译 warning 可忽略(非功能错误)。 diff --git a/docs/frontend-joint-checklist.md b/docs/frontend-joint-checklist.md new file mode 100644 index 0000000..9b6a4c5 --- /dev/null +++ b/docs/frontend-joint-checklist.md @@ -0,0 +1,98 @@ +# Ops-Assistant 前后端联调检查清单(给前端/Gemini) + +更新日期:2026-03-10 + +--- + +## A. 统一基础 + +- [ ] 所有请求走 `/api/v1/*`(禁用新代码调用 legacy 路由) +- [ ] `apiClient` 统一解析 `{code,message,data}` +- [ ] 全局错误 toast 仅展示 `message` +- [ ] 鉴权失败统一跳转登录 + +--- + +## B. 登录后初始化 + +- [ ] 调 `GET /api/v1/me` +- [ ] 用 `effective_capabilities` 控制页面和按钮显隐 +- [ ] 没有权限时不渲染可操作按钮 + +--- + +## C. Dashboard + +- [ ] 首屏并行请求: + - `GET /api/v1/dashboard/summary` + - `GET /api/v1/dashboard/overview` +- [ ] 状态卡片展示 jobs 统计、模块状态、通道状态 +- [ ] 10~20 秒轮询刷新 + +--- + +## D. Modules 页面 + +- [ ] 列表:`GET /api/v1/modules` +- [ ] 开关:`POST /api/v1/modules/:module/toggle` + - [ ] reason 必填 + - [ ] 处理 `message=noop` + - [ ] cpa 禁用失败提示 +- [ ] 成功后刷新 modules + overview + +--- + +## E. Ops Jobs 页面 + +- [ ] 列表:`GET /api/v1/ops/jobs` +- [ ] 支持筛选字段: + - [ ] status + - [ ] target + - [ ] runbook + - [ ] request_id + - [ ] operator + - [ ] risk_level + - [ ] q + - [ ] from/to(RFC3339) +- [ ] 展示后端回显的 `filters` +- [ ] 详情:`GET /api/v1/ops/jobs/:id` + - [ ] step_stats + - [ ] step_total + - [ ] duration.job_ms / duration.steps_ms_sum +- [ ] request_id 反查:`GET /api/v1/ops/jobs/request/:requestID` +- [ ] cancel/retry: + - [ ] reason 必填 + - [ ] 按权限按钮显隐(can_cancel_ops / can_retry_ops) + +--- + +## F. Channels 页面 + +- [ ] `GET /api/v1/admin/channels` +- [ ] patch/publish/reload/apply 流程联通 +- [ ] secrets 脱敏显示并正确提交 + +--- + +## G. Audit 页面 + +- [ ] `GET /api/v1/admin/audit` +- [ ] 支持 from/to、action、target_type、result、actor_id + +--- + +## H. Legacy 迁移看板(管理页) + +- [ ] `GET /api/v1/admin/legacy/usage` +- [ ] `GET /api/v1/admin/legacy/trend?days=7` +- [ ] `GET /api/v1/admin/legacy/readiness?days=7&zero_days=3` +- [ ] 显示 `ready` + `recommendation` + +--- + +## I. 验收标准 + +- [ ] 前端无新代码依赖 legacy 路由 +- [ ] 所有核心页面可在 ops-assistant 独立运行 +- [ ] 权限控制、错误处理、轮询刷新行为正常 +- [ ] readiness 达标后可计划 legacy 下线窗口 diff --git a/docs/module-adapter-template.md b/docs/module-adapter-template.md new file mode 100644 index 0000000..4d3cd92 --- /dev/null +++ b/docs/module-adapter-template.md @@ -0,0 +1,37 @@ +# Module Adapter Template + +目标:新功能模块以最小改动接入 `ops-assistant`。 + +## 必备文件 + +- `internal/module//module.go` +- `runbooks/_*.yaml` + +## 推荐实现 + +1. 在 `module.go` 内解析命令前缀(如 `/cf`) +2. 构建 `core/module.Request` +3. 统一调用 `core/module.Runner.Run(...)` +4. 所有 gate(flag/confirm/dry-run)通过 `Request.Gate` 声明 + +## 最小示例(伪代码) + +```go +req := coremodule.Request{ + RunbookName: "cf_dns_upsert", + Inputs: map[string]string{"zone": zone, "name": name}, + Meta: runbook.RunMeta{Target:"cf", RiskLevel:"medium"}, + Gate: coremodule.Gate{NeedFlag:"allow_cf_write", RequireConfirm:true, ExpectedToken:"YES_CF", AllowDryRun:true}, + DryRun: dryRun, + ConfirmToken: confirm, +} +jobID, out, err := runner.Run(cmd.Raw, userID, req) +``` + +## 接入检查 + +- [ ] 命令不直接执行 shell/ssh +- [ ] 使用统一 Runner +- [ ] 风险操作有 Gate +- [ ] 返回 job_id 可追踪 +- [ ] runbook step 可审计 diff --git a/docs/multi-platform-channel-deploy.md b/docs/multi-platform-channel-deploy.md new file mode 100644 index 0000000..12f8e7c --- /dev/null +++ b/docs/multi-platform-channel-deploy.md @@ -0,0 +1,72 @@ +# Ops-Assistant 多平台渠道配置与回调部署说明 + +## 已支持平台 +- 官方 QQ Bot(qqbot_official) +- Telegram Bot(telegram) +- 飞书 Bot(feishu) + +## 配置优先级 +- 启动时:`数据库 channel_configs` > `config.yaml` +- 建议使用后台页面维护渠道配置:`/channels` + +## 后台入口 +- 渠道配置页:`/channels` +- 渠道 API: + - `GET /api/v1/admin/channels` + - `PATCH /api/v1/admin/channels/:platform` + - `POST /api/v1/admin/channels/:platform/test` +- 审计查询:`GET /api/v1/admin/audit` + +## 回调地址 +- 飞书 webhook: `POST /webhook/feishu` + +### 飞书事件订阅配置 +1. 在飞书开发者后台启用事件订阅 +2. 请求网址填:`https://<你的域名>/webhook/feishu` +3. 订阅事件:`im.message.receive_v1` +4. 将 `verification_token`、`app_id`、`app_secret` 写入渠道 secrets JSON + +## 渠道 secrets JSON 示例 + +### telegram +```json +{ + "token": "123456:ABCDEF" +} +``` + +### qqbot_official +```json +{ + "appid": "102857798", + "secret": "xxxxxx" +} +``` + +### feishu +```json +{ + "app_id": "cli_xxx", + "app_secret": "xxx", + "verification_token": "xxx", + "encrypt_key": "optional" +} +``` + +## 连接测试说明 +- Telegram:调用 `getMe` +- QQ:调用 `getAppAccessToken` +- 飞书:调用 `tenant_access_token/internal` + +测试成功会把渠道状态写成 `ok`,失败写成 `error`。 + +## 幂等去重 +- 三平台入站统一落 `message_dedup`,避免重复处理: + - telegram: `tg:` + - qqbot_official: `qq::` + - feishu: `event_id`(回退 message_id) + +## 运行建议 +- 对公网暴露前请加 HTTPS(飞书回调必需) +- 建议将管理后台放在内网或反代鉴权后访问 +- 定期审计 `audit_logs` 里渠道配置修改记录 diff --git a/docs/ops-assistant-v1.md b/docs/ops-assistant-v1.md new file mode 100644 index 0000000..1e9faf9 --- /dev/null +++ b/docs/ops-assistant-v1.md @@ -0,0 +1,200 @@ +# 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 ` +- `/cpa codex clean` +- `/cpa codex test` +- `/cpa codex isolate` + +### 约束 +- 仅允许上述命令(严格白名单) +- 每个命令映射到唯一 runbook +- 不支持自由 shell 指令输入 + +--- + +## Runbook DSL(v1) + +仅支持这些动作: + +- `ssh.exec`:远程执行固定命令 +- `http.get` / `http.post`:调用固定接口 +- `file.upload`:上传压缩包到 staging +- `file.extract`:解压到 staging +- `assert.json`:断言字段值 +- `sleep`:延迟 + +每个步骤必须有: + +- `id` +- `action` +- `on_fail`(`stop` | `continue`) + +所有变量只能来自: + +- `inputs.*`(命令参数) +- `env.*`(服务端配置) +- `steps..output.*`(前置步骤输出) + +--- + +## 审计与可追溯 + +建议新增两张表: + +### 1) `ops_jobs` +- `id` +- `command` +- `runbook` +- `operator_id` +- `status`(pending/running/success/failed) +- `started_at` +- `ended_at` + +### 2) `ops_job_steps` +- `id` +- `job_id` +- `step_id` +- `action` +- `status` +- `rc` +- `stdout_tail` +- `stderr_tail` +- `started_at` +- `ended_at` + +> 所有 Bot 命令回执都返回 `job_id`,便于追踪。 + +--- + +## 风险控制 + +- 默认 `dry-run=true`(先演练) +- 高风险步骤(restore/import)必须: + - 双确认(命令 + confirm token) + - feature flag 开启才允许执行 +- 审计日志不写明文 secrets +- 全部 secrets 走现有 `channel` 加密机制存储 + +--- + +## 与现有系统的集成点(独立项目并存) + +1. **Bot 层**:在 Telegram/QQ/飞书消息处理中增加 `/cpa` 命令分流 +2. **Web 层**:新增 `/ops` 页面查看任务状态与步骤日志 +3. **模型层**:新增 `ops_jobs` / `ops_job_steps` +4. **配置层**:增加 `ops.targets`(如 hwsg/wjynl) + +--- + +## 阶段性进度汇报原则(必须遵守) + +**模板(四要素)** +1) 阶段名(设计/实现/自测/上线等) +2) 已执行动作(具体到做了什么) +3) 可验证证据(日志、产物路径、返回码、截图等) +4) 下一步与前置条件(还差什么、需谁确认) + +**硬规则** +- 无证据不使用“完成/已执行/进行中”等措辞 +- 遇到卡点必须立即说明:卡点 + 原因 + 需要的唯一输入 +- 先清单后执行的任务,未确认不得执行 + +--- + +## 事故复盘(2026-03-13,CF DNS Runbook) + +详见:`docs/cf-dns-runbook-incident-20260313.md` + +要点摘要: +- 认证方式误判(API Token 被当作 API Key)。 +- heredoc/转义在 ssh.exec 中导致脚本未执行。 +- 最终采用 base64 + python3 -c exec 稳定执行。 +- 强化“阶段性汇报必须带证据”的纪律。 + +--- + +## 验收标准(v1) + +1. `/cpa status` 能返回结构化结果(非自由文本) +2. `/cpa usage backup` 能输出:备份路径 + sha256 + usage 快照 +3. `/cpa usage restore ` 支持双校验(立即 + 延迟) +4. 任一步骤失败时可追溯到具体 step 日志 +5. 未授权命令必须被拒绝并记录审计 + +--- + +## 当前已落地(2026-03-10) + +1. 已打通命令入口:Telegram / QQ / 飞书 +2. 已支持命令: + - `/cpa status` + - `/cpa usage backup` + - `/cpa usage restore ` +3. 已提供查询接口: + - `GET /api/v1/ops/jobs` + - `GET /api/v1/ops/jobs/:id`(含 steps) +4. `assert.json` 已支持真实 JSON 路径断言 +5. 已增加安全闸门: + - `allow_ops_restore` feature flag(默认 false) + - restore 需要 `--confirm YES_RESTORE` + - 支持 `--dry-run` +6. 已支持 `ops_targets` 目标主机表(优先解析 target 名称) + +## 当前 Core 强化(2026-03-10 第二阶段) + +1. 同 target 串行锁(避免并发覆盖) +2. 作业元信息增强:`target/risk_level/request_id/confirm_hash` +3. 统一错误码前缀(如 `ERR_FEATURE_DISABLED` / `ERR_CONFIRM_REQUIRED` / `ERR_STEP_FAILED`) +4. step 超时控制(默认 45s) +5. 任务取消接口:`POST /api/v1/ops/jobs/:id/cancel` + +## 下一步落地建议 + +1. 为取消操作增加权限细分(`ops.cancel`) +2. 增加 job 重试接口(仅失败任务可重试) +3. 增加步骤级超时配置(runbook 可覆盖) +4. 增加 Cloudflare / Mail 模块(在 Core 验收完成后) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..552d70c --- /dev/null +++ b/go.mod @@ -0,0 +1,52 @@ +module ops-assistant + +go 1.22 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/tencent-connect/botgo v0.2.1 + github.com/yanyiwu/gojieba v1.3.0 + gopkg.in/yaml.v3 v3.0.1 + gorm.io/driver/sqlite v1.5.4 + gorm.io/gorm v1.25.5 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/go-resty/resty/v2 v2.6.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-sqlite3 v1.14.17 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/tidwall/gjson v1.9.3 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + github.com/wcharczuk/go-chart/v2 v2.1.2 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/image v0.18.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/oauth2 v0.23.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..38b9066 --- /dev/null +++ b/go.sum @@ -0,0 +1,272 @@ +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= +github.com/go-resty/resty/v2 v2.6.0 h1:joIR5PNLM2EFqqESUjCMGXrWmXNHEU9CEiK813oKYS4= +github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= +github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/tencent-connect/botgo v0.2.1 h1:+BrTt9Zh+awL28GWC4g5Na3nQaGRWb0N5IctS8WqBCk= +github.com/tencent-connect/botgo v0.2.1/go.mod h1:oO1sG9ybhXNickvt+CVym5khwQ+uKhTR+IhTqEfOVsI= +github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E= +github.com/tidwall/gjson v1.9.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E= +github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ= +github.com/yanyiwu/gojieba v1.3.0 h1:6VeaPOR+MawnImdeSvWNr7rP4tvUfnGlEKaoBnR33Ds= +github.com/yanyiwu/gojieba v1.3.0/go.mod h1:54wkP7sMJ6bklf7yPl6F+JG71dzVUU1WigZbR47nGdY= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= +golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0= +gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/bot/telegram.go b/internal/bot/telegram.go new file mode 100644 index 0000000..7c4afcd --- /dev/null +++ b/internal/bot/telegram.go @@ -0,0 +1,237 @@ +package bot + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + 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" +) + +// DefaultUserID 统一用户ID,使所有平台共享同一份账本 +const DefaultUserID int64 = 1 + +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, opsSvc *ops.Service) (*TGBot, error) { + bot, err := tgbotapi.NewBotAPI(token) + if err != nil { + return nil, err + } + return &TGBot{api: bot, finance: finance, db: db, opsSvc: opsSvc}, nil +} + +func (b *TGBot) Start(ctx context.Context) { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + updates := b.api.GetUpdatesChan(u) + + log.Println("🚀 Telegram Bot 已启动") + + for { + select { + case <-ctx.Done(): + log.Println("⏳ Telegram Bot 正在停止...") + b.api.StopReceivingUpdates() + return + case update, ok := <-updates: + if !ok { + return + } + if update.Message == nil || update.Message.Text == "" { + continue + } + + eventID := fmt.Sprintf("tg:%d", update.UpdateID) + if b.isDuplicate(eventID) { + continue + } + log.Printf("📩 inbound platform=telegram event=%s chat=%d user=%d text=%q", eventID, update.Message.Chat.ID, update.Message.From.ID, strings.TrimSpace(update.Message.Text)) + b.handleMessage(update.Message) + } + } +} + +func (b *TGBot) isDuplicate(eventID string) bool { + if b.db == nil || strings.TrimSpace(eventID) == "" { + return false + } + var existed models.MessageDedup + if err := b.db.Where("platform = ? AND event_id = ?", "telegram", eventID).First(&existed).Error; err == nil { + return true + } + _ = b.db.Create(&models.MessageDedup{Platform: "telegram", EventID: eventID, ProcessedAt: time.Now()}).Error + return false +} + +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 = "🛠️ 欢迎使用 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 - 欢迎信息" + + case text == "/today": + today := time.Now().Format("2006-01-02") + items, err := b.finance.GetTransactionsByDate(DefaultUserID, today) + if err != nil { + reply = "❌ 查询失败" + } else if len(items) == 0 { + reply = fmt.Sprintf("📭 %s 暂无消费记录", today) + } else { + 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)) + reply = sb.String() + } + + case text == "/chart": + b.sendMonthlyChart(chatID) + return + + case text == "/week": + b.sendWeeklyChart(chatID) + return + + case text == "/list": + items, err := b.finance.GetTransactions(DefaultUserID, 10) + if err != nil { + reply = "❌ 查询失败" + } else if len(items) == 0 { + reply = "📭 暂无记录" + } else { + 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())) + } + reply = sb.String() + } + + case strings.HasPrefix(text, "/"): + reply = "❓ 未知命令,输入 /help 查看帮助" + + default: + amount, category, err := b.finance.AddTransaction(DefaultUserID, text) + if err != nil { + reply = "❌ 记账失败,请稍后重试" + log.Printf("记账失败: %v", err) + } else if amount == 0 { + reply = "📍 没看到金额,这笔花了多少钱?" + } else { + amountYuan := float64(amount) / 100.0 + reply = fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, amountYuan, text) + } + } + + m := tgbotapi.NewMessage(chatID, reply) + if _, err := b.api.Send(m); err != nil { + log.Printf("发送消息失败 chat=%d: %v", chatID, err) + } +} + +func (b *TGBot) sendMonthlyChart(chatID int64) { + now := time.Now() + dateFrom := now.Format("2006-01") + "-01" + dateTo := now.Format("2006-01-02") + title := fmt.Sprintf("%d年%d月消费分类", now.Year(), now.Month()) + + stats, err := b.finance.GetCategoryStats(DefaultUserID, dateFrom, dateTo) + if err != nil || len(stats) == 0 { + m := tgbotapi.NewMessage(chatID, "📭 本月暂无消费数据") + b.api.Send(m) + return + } + + imgData, err := xchart.GeneratePieChart(stats, title) + if err != nil { + log.Printf("生成饼图失败: %v", err) + m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败") + b.api.Send(m) + return + } + + var total int64 + var totalCount int + for _, s := range stats { + total += s.Total + totalCount += s.Count + } + caption := fmt.Sprintf("📊 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0) + + photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData}) + photo.Caption = caption + if _, err := b.api.Send(photo); err != nil { + log.Printf("发送图表失败 chat=%d: %v", chatID, err) + } +} + +func (b *TGBot) sendWeeklyChart(chatID int64) { + now := time.Now() + dateFrom := now.AddDate(0, 0, -6).Format("2006-01-02") + dateTo := now.Format("2006-01-02") + title := fmt.Sprintf("近7天消费趋势 (%s ~ %s)", dateFrom[5:], dateTo[5:]) + + stats, err := b.finance.GetDailyStats(DefaultUserID, dateFrom, dateTo) + if err != nil || len(stats) == 0 { + m := tgbotapi.NewMessage(chatID, "📭 近7天暂无消费数据") + b.api.Send(m) + return + } + + imgData, err := xchart.GenerateBarChart(stats, title) + if err != nil { + log.Printf("生成柱状图失败: %v", err) + m := tgbotapi.NewMessage(chatID, "❌ 图表生成失败") + b.api.Send(m) + return + } + + var total int64 + var totalCount int + for _, s := range stats { + total += s.Total + totalCount += s.Count + } + caption := fmt.Sprintf("📈 %s\n💰 共 %d 笔,合计 %.2f 元", title, totalCount, float64(total)/100.0) + + photo := tgbotapi.NewPhoto(chatID, tgbotapi.FileBytes{Name: "chart.png", Bytes: imgData}) + photo.Caption = caption + if _, err := b.api.Send(photo); err != nil { + log.Printf("发送图表失败 chat=%d: %v", chatID, err) + } +} diff --git a/internal/channel/channel.go b/internal/channel/channel.go new file mode 100644 index 0000000..dc0f3f6 --- /dev/null +++ b/internal/channel/channel.go @@ -0,0 +1,442 @@ +package channel + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "ops-assistant/config" + "ops-assistant/models" + + "golang.org/x/crypto/pbkdf2" + "gorm.io/gorm" +) + +type UnifiedMessage struct { + Platform string `json:"platform"` + EventID string `json:"event_id"` + ChatID string `json:"chat_id"` + UserID string `json:"user_id"` + Text string `json:"text"` +} + +const ( + encPrefixV1 = "enc:v1:" + encPrefixV2 = "enc:v2:" +) + +var secretCipherV1 *cipherContext +var secretCipherV2 *cipherContext + +type cipherContext struct { + aead cipher.AEAD +} + +func InitSecretCipher(key string) error { + k1 := deriveKey32Legacy(key) + block1, err := aes.NewCipher(k1) + if err != nil { + return err + } + aead1, err := cipher.NewGCM(block1) + if err != nil { + return err + } + secretCipherV1 = &cipherContext{aead: aead1} + + k2 := deriveKey32V2(key) + block2, err := aes.NewCipher(k2) + if err != nil { + return err + } + aead2, err := cipher.NewGCM(block2) + if err != nil { + return err + } + secretCipherV2 = &cipherContext{aead: aead2} + return nil +} + +func deriveKey32Legacy(s string) []byte { + b := []byte(s) + out := make([]byte, 32) + if len(b) >= 32 { + copy(out, b[:32]) + return out + } + copy(out, b) + for i := len(b); i < 32; i++ { + out[i] = byte((i * 131) % 251) + } + return out +} + +func deriveKey32V2(s string) []byte { + if strings.TrimSpace(s) == "" { + return make([]byte, 32) + } + // PBKDF2 for deterministic 32-byte key derivation + return pbkdf2.Key([]byte(s), []byte("ops-assistant-v1"), 200000, 32, sha256.New) +} + +func encryptString(plain string) (string, error) { + if secretCipherV2 == nil { + return plain, errors.New("cipher not initialized") + } + nonce := make([]byte, secretCipherV2.aead.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + ciphertext := secretCipherV2.aead.Seal(nil, nonce, []byte(plain), nil) + buf := append(nonce, ciphertext...) + return encPrefixV2 + base64.StdEncoding.EncodeToString(buf), nil +} + +func decryptString(raw string) (string, error) { + if !strings.HasPrefix(raw, encPrefixV1) && !strings.HasPrefix(raw, encPrefixV2) { + return raw, nil + } + if strings.HasPrefix(raw, encPrefixV2) { + if secretCipherV2 == nil { + return "", errors.New("cipher not initialized") + } + data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV2)) + if err != nil { + return "", err + } + ns := secretCipherV2.aead.NonceSize() + if len(data) <= ns { + return "", errors.New("invalid ciphertext") + } + nonce := data[:ns] + ct := data[ns:] + pt, err := secretCipherV2.aead.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(pt), nil + } + + if secretCipherV1 == nil { + return "", errors.New("cipher not initialized") + } + data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV1)) + if err != nil { + return "", err + } + ns := secretCipherV1.aead.NonceSize() + if len(data) <= ns { + return "", errors.New("invalid ciphertext") + } + nonce := data[:ns] + ct := data[ns:] + pt, err := secretCipherV1.aead.Open(nil, nonce, ct, nil) + if err != nil { + return "", err + } + return string(pt), nil +} + +func maybeDecrypt(raw string) string { + if strings.TrimSpace(raw) == "" { + return raw + } + pt, err := decryptString(raw) + if err != nil { + return raw + } + return pt +} + +func MaybeDecryptPublic(raw string) string { + return maybeDecrypt(raw) +} + +func EncryptSecretJSON(raw string) string { + if strings.TrimSpace(raw) == "" { + return raw + } + if strings.HasPrefix(raw, encPrefixV1) || strings.HasPrefix(raw, encPrefixV2) { + return raw + } + if secretCipherV2 == nil { + return raw + } + enc, err := encryptString(raw) + if err != nil { + return raw + } + return enc +} + +type telegramSecret struct { + Token string `json:"token"` +} + +type qqSecret struct { + AppID string `json:"appid"` + Secret string `json:"secret"` +} + +type feishuSecret struct { + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + VerificationToken string `json:"verification_token"` + EncryptKey string `json:"encrypt_key"` +} + +func parseJSON(raw string, out any) { + if strings.TrimSpace(raw) == "" { + return + } + _ = json.Unmarshal([]byte(raw), out) +} + +// ApplyChannelConfig 从数据库渠道配置覆盖运行时配置(优先级:DB > YAML) +func ApplyChannelConfig(db *gorm.DB, cfg *config.Config) error { + var rows []models.ChannelConfig + if err := db.Find(&rows).Error; err != nil { + return err + } + + for _, row := range rows { + switch row.Platform { + case "telegram": + sec := telegramSecret{} + parseJSON(maybeDecrypt(row.SecretJSON), &sec) + cfg.Telegram.Enabled = row.Enabled + if strings.TrimSpace(sec.Token) != "" { + cfg.Telegram.Token = strings.TrimSpace(sec.Token) + } + case "qqbot_official": + sec := qqSecret{} + parseJSON(maybeDecrypt(row.SecretJSON), &sec) + cfg.QQBot.Enabled = row.Enabled + if strings.TrimSpace(sec.AppID) != "" { + cfg.QQBot.AppID = strings.TrimSpace(sec.AppID) + } + if strings.TrimSpace(sec.Secret) != "" { + cfg.QQBot.Secret = strings.TrimSpace(sec.Secret) + } + case "feishu": + sec := feishuSecret{} + parseJSON(maybeDecrypt(row.SecretJSON), &sec) + cfg.Feishu.Enabled = row.Enabled + if strings.TrimSpace(sec.AppID) != "" { + cfg.Feishu.AppID = strings.TrimSpace(sec.AppID) + } + if strings.TrimSpace(sec.AppSecret) != "" { + cfg.Feishu.AppSecret = strings.TrimSpace(sec.AppSecret) + } + if strings.TrimSpace(sec.VerificationToken) != "" { + cfg.Feishu.VerificationToken = strings.TrimSpace(sec.VerificationToken) + } + if strings.TrimSpace(sec.EncryptKey) != "" { + cfg.Feishu.EncryptKey = strings.TrimSpace(sec.EncryptKey) + } + } + } + return nil +} + +func httpClient() *http.Client { + return &http.Client{Timeout: 8 * time.Second} +} + +func TestChannelConnectivity(ctx context.Context, row models.ChannelConfig) (status, detail string) { + if !row.Enabled { + return "disabled", "渠道未启用" + } + switch row.Platform { + case "telegram": + sec := telegramSecret{} + parseJSON(maybeDecrypt(row.SecretJSON), &sec) + if strings.TrimSpace(sec.Token) == "" { + return "error", "telegram token 为空" + } + url := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", strings.TrimSpace(sec.Token)) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := httpClient().Do(req) + if err != nil { + return "error", err.Error() + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != 200 || !strings.Contains(string(body), `"ok":true`) { + return "error", fmt.Sprintf("telegram getMe失败: http=%d", resp.StatusCode) + } + return "ok", "telegram getMe 成功" + + case "qqbot_official": + sec := qqSecret{} + parseJSON(maybeDecrypt(row.SecretJSON), &sec) + if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.Secret) == "" { + return "error", "qq appid/secret 为空" + } + payload, _ := json.Marshal(map[string]string{"appId": strings.TrimSpace(sec.AppID), "clientSecret": strings.TrimSpace(sec.Secret)}) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://bots.qq.com/app/getAppAccessToken", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return "error", err.Error() + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != 200 || !strings.Contains(string(body), "access_token") { + return "error", fmt.Sprintf("qq access token 获取失败: http=%d", resp.StatusCode) + } + return "ok", "qq access token 获取成功" + + case "feishu": + sec := feishuSecret{} + parseJSON(maybeDecrypt(row.SecretJSON), &sec) + if strings.TrimSpace(sec.AppID) == "" || strings.TrimSpace(sec.AppSecret) == "" { + return "error", "feishu app_id/app_secret 为空" + } + tk, err := GetFeishuTenantToken(ctx, strings.TrimSpace(sec.AppID), strings.TrimSpace(sec.AppSecret)) + if err != nil || strings.TrimSpace(tk) == "" { + if err == nil { + err = fmt.Errorf("token 为空") + } + return "error", err.Error() + } + return "ok", "feishu tenant_access_token 获取成功" + default: + return "error", "未知平台" + } +} + +func ParseFeishuInbound(body []byte, verificationToken string) (*UnifiedMessage, string, error) { + // url_verification + var verifyReq struct { + Type string `json:"type"` + Challenge string `json:"challenge"` + Token string `json:"token"` + } + if err := json.Unmarshal(body, &verifyReq); err == nil && verifyReq.Type == "url_verification" { + if strings.TrimSpace(verificationToken) != "" && verifyReq.Token != verificationToken { + return nil, "", fmt.Errorf("verification token mismatch") + } + return nil, verifyReq.Challenge, nil + } + + var event struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + } `json:"header"` + Event struct { + Sender struct { + SenderID struct { + OpenID string `json:"open_id"` + } `json:"sender_id"` + } `json:"sender"` + Message struct { + MessageID string `json:"message_id"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + } `json:"message"` + } `json:"event"` + } + if err := json.Unmarshal(body, &event); err != nil { + return nil, "", err + } + + if event.Header.EventType != "im.message.receive_v1" { + return nil, "", nil + } + + eventID := strings.TrimSpace(event.Header.EventID) + if eventID == "" { + eventID = strings.TrimSpace(event.Event.Message.MessageID) + } + if eventID == "" { + return nil, "", fmt.Errorf("missing event id") + } + + var content struct { + Text string `json:"text"` + } + _ = json.Unmarshal([]byte(event.Event.Message.Content), &content) + text := strings.TrimSpace(content.Text) + if text == "" { + return nil, "", nil + } + + return &UnifiedMessage{ + Platform: "feishu", + EventID: eventID, + ChatID: strings.TrimSpace(event.Event.Message.ChatID), + UserID: strings.TrimSpace(event.Event.Sender.SenderID.OpenID), + Text: text, + }, "", nil +} + +func GetFeishuTenantToken(ctx context.Context, appID, appSecret string) (string, error) { + payload, _ := json.Marshal(map[string]string{"app_id": appID, "app_secret": appSecret}) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if resp.StatusCode != 200 { + return "", fmt.Errorf("http=%d", resp.StatusCode) + } + + var out struct { + Code int `json:"code"` + Msg string `json:"msg"` + TenantAccessToken string `json:"tenant_access_token"` + } + if err := json.Unmarshal(body, &out); err != nil { + return "", err + } + if out.Code != 0 || strings.TrimSpace(out.TenantAccessToken) == "" { + if out.Msg == "" { + out.Msg = "获取token失败" + } + return "", fmt.Errorf(out.Msg) + } + return out.TenantAccessToken, nil +} + +func SendFeishuText(ctx context.Context, tenantToken, receiveID, text string) error { + contentBytes, _ := json.Marshal(map[string]string{"text": text}) + payload, _ := json.Marshal(map[string]string{ + "receive_id": receiveID, + "msg_type": "text", + "content": string(contentBytes), + }) + url := "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id" + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tenantToken) + resp, err := httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + if resp.StatusCode != 200 { + return fmt.Errorf("http=%d", resp.StatusCode) + } + if !strings.Contains(string(body), `"code":0`) { + return fmt.Errorf("feishu send failed") + } + return nil +} diff --git a/internal/chart/chart.go b/internal/chart/chart.go new file mode 100644 index 0000000..389fe61 --- /dev/null +++ b/internal/chart/chart.go @@ -0,0 +1,126 @@ +package chart + +import ( + "bytes" + "fmt" + "math" + + "ops-assistant/internal/service" + + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) + +// 分类对应的颜色 +var categoryColors = []drawing.Color{ + {R: 255, G: 99, B: 132, A: 255}, // 红 + {R: 54, G: 162, B: 235, A: 255}, // 蓝 + {R: 255, G: 206, B: 86, A: 255}, // 黄 + {R: 75, G: 192, B: 192, A: 255}, // 青 + {R: 153, G: 102, B: 255, A: 255}, // 紫 + {R: 255, G: 159, B: 64, A: 255}, // 橙 + {R: 46, G: 204, B: 113, A: 255}, // 绿 + {R: 231, G: 76, B: 60, A: 255}, // 深红 + {R: 52, G: 73, B: 94, A: 255}, // 深蓝灰 + {R: 241, G: 196, B: 15, A: 255}, // 金 +} + +// GeneratePieChart 生成分类占比饼图 +func GeneratePieChart(stats []service.CategoryStat, title string) ([]byte, error) { + if len(stats) == 0 { + return nil, fmt.Errorf("no data") + } + + var total float64 + for _, s := range stats { + total += float64(s.Total) + } + + var values []chart.Value + for i, s := range stats { + yuan := float64(s.Total) / 100.0 + pct := float64(s.Total) / total * 100 + label := fmt.Sprintf("%s %.0f元(%.0f%%)", s.Category, yuan, pct) + values = append(values, chart.Value{ + Value: yuan, + Label: label, + Style: chart.Style{ + FillColor: categoryColors[i%len(categoryColors)], + StrokeColor: drawing.ColorWhite, + StrokeWidth: 2, + }, + }) + } + + pie := chart.PieChart{ + Title: title, + Width: 600, + Height: 500, + TitleStyle: chart.Style{ + FontSize: 16, + }, + Values: values, + } + + buf := &bytes.Buffer{} + if err := pie.Render(chart.PNG, buf); err != nil { + return nil, fmt.Errorf("render pie chart: %w", err) + } + return buf.Bytes(), nil +} + +// GenerateBarChart 生成每日消费柱状图 +func GenerateBarChart(stats []service.DailyStat, title string) ([]byte, error) { + if len(stats) == 0 { + return nil, fmt.Errorf("no data") + } + + var values []chart.Value + var maxVal float64 + for _, s := range stats { + yuan := float64(s.Total) / 100.0 + if yuan > maxVal { + maxVal = yuan + } + // 日期只取 MM-DD + dateLabel := s.Date + if len(s.Date) > 5 { + dateLabel = s.Date[5:] + } + values = append(values, chart.Value{ + Value: yuan, + Label: dateLabel, + Style: chart.Style{ + FillColor: drawing.Color{R: 54, G: 162, B: 235, A: 255}, + StrokeColor: drawing.Color{R: 54, G: 162, B: 235, A: 255}, + StrokeWidth: 1, + }, + }) + } + + bar := chart.BarChart{ + Title: title, + Width: 600, + Height: 400, + TitleStyle: chart.Style{ + FontSize: 16, + }, + YAxis: chart.YAxis{ + Range: &chart.ContinuousRange{ + Min: 0, + Max: math.Ceil(maxVal*1.2/10) * 10, + }, + ValueFormatter: func(v interface{}) string { + return fmt.Sprintf("%.0f", v) + }, + }, + BarWidth: 40, + Bars: values, + } + + buf := &bytes.Buffer{} + if err := bar.Render(chart.PNG, buf); err != nil { + return nil, fmt.Errorf("render bar chart: %w", err) + } + return buf.Bytes(), nil +} diff --git a/internal/core/ai/advisor.go b/internal/core/ai/advisor.go new file mode 100644 index 0000000..ad708db --- /dev/null +++ b/internal/core/ai/advisor.go @@ -0,0 +1,24 @@ +package ai + +type Mode string + +const ( + ModeOff Mode = "off" + ModeSuggest Mode = "suggest" + ModeExplain Mode = "explain" +) + +type Advisor interface { + Suggest(userInput string) (string, error) + Explain(result string) (string, error) +} + +type NoopAdvisor struct{} + +func (NoopAdvisor) Suggest(userInput string) (string, error) { + return "", nil +} + +func (NoopAdvisor) Explain(result string) (string, error) { + return "", nil +} diff --git a/internal/core/ai/client.go b/internal/core/ai/client.go new file mode 100644 index 0000000..b8c260b --- /dev/null +++ b/internal/core/ai/client.go @@ -0,0 +1,103 @@ +package ai + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +type Client struct { + BaseURL string + APIKey string + Model string + Timeout time.Duration +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` + Temperature float64 `json:"temperature"` +} + +type chatResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + } `json:"error"` +} + +func (c *Client) Suggest(userInput string) (string, error) { + return c.chat(userInput) +} + +func (c *Client) Explain(result string) (string, error) { + return "", nil +} + +func commandGuide() string { + b, err := os.ReadFile("docs/ai_command_guide.md") + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func (c *Client) chat(userInput string) (string, error) { + if strings.TrimSpace(c.BaseURL) == "" || strings.TrimSpace(c.APIKey) == "" || strings.TrimSpace(c.Model) == "" { + return "", errors.New("ai config missing") + } + base := strings.TrimRight(c.BaseURL, "/") + url := base + "/chat/completions" + + sys := "你是命令翻译器。把用户的自然语言转换成系统支持的标准命令。只输出一行命令,不要解释。若无法确定,输出 FAIL。\n\n可用命令知识库:\n" + commandGuide() + "\n\n规则:严格按命令格式输出。缺少关键参数时输出 FAIL。不要猜测 zone_id/record_id/backup_id。" + req := chatRequest{ + Model: c.Model, + Messages: []chatMessage{ + {Role: "system", Content: sys}, + {Role: "user", Content: userInput}, + }, + Temperature: 0, + } + body, _ := json.Marshal(req) + client := &http.Client{Timeout: c.Timeout} + httpReq, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) + + resp, err := client.Do(httpReq) + if err != nil { + return "", err + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if resp.StatusCode == 429 { + return "", fmt.Errorf("ai rate limited") + } + return "", fmt.Errorf("ai http %d", resp.StatusCode) + } + var out chatResponse + if err := json.Unmarshal(b, &out); err != nil { + return "", err + } + if out.Error != nil && out.Error.Message != "" { + return "", errors.New(out.Error.Message) + } + if len(out.Choices) == 0 { + return "", errors.New("empty ai response") + } + return strings.TrimSpace(out.Choices[0].Message.Content), nil +} diff --git a/internal/core/ai/loader.go b/internal/core/ai/loader.go new file mode 100644 index 0000000..1347963 --- /dev/null +++ b/internal/core/ai/loader.go @@ -0,0 +1,40 @@ +package ai + +import ( + "strconv" + "strings" + "time" + + "ops-assistant/models" + + "gorm.io/gorm" +) + +func LoadClient(db *gorm.DB) *Client { + if db == nil { + return nil + } + get := func(key string) string { + var sset models.AppSetting + if err := db.Where("key = ?", key).First(&sset).Error; err == nil { + return strings.TrimSpace(sset.Value) + } + return "" + } + if strings.ToLower(get("ai_enabled")) != "true" { + return nil + } + baseURL := get("ai_base_url") + apiKey := get("ai_api_key") + model := get("ai_model") + to := 15 + if v := get("ai_timeout_seconds"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + to = n + } + } + if baseURL == "" || apiKey == "" || model == "" { + return nil + } + return &Client{BaseURL: baseURL, APIKey: apiKey, Model: model, Timeout: time.Duration(to) * time.Second} +} diff --git a/internal/core/command/parser.go b/internal/core/command/parser.go new file mode 100644 index 0000000..b2daeb4 --- /dev/null +++ b/internal/core/command/parser.go @@ -0,0 +1,67 @@ +package command + +import ( + "fmt" + "strings" +) + +type ParsedCommand struct { + Raw string + Name string + Args []string + Module string +} + +func Parse(raw string) (*ParsedCommand, error) { + text := strings.TrimSpace(raw) + if text == "" || !strings.HasPrefix(text, "/") { + return nil, fmt.Errorf("not a command") + } + parts := strings.Fields(text) + if len(parts) == 0 { + return nil, fmt.Errorf("empty command") + } + + cmd := &ParsedCommand{Raw: text, Name: parts[0]} + if len(parts) > 1 { + cmd.Args = parts[1:] + } + mod := strings.TrimPrefix(cmd.Name, "/") + if i := strings.Index(mod, "@"); i > 0 { + mod = mod[:i] + } + if mod != "" { + cmd.Module = mod + } + return cmd, nil +} + +// ParseWithInputs: 支持 /cf dns list 这种输入参数写入 runbook inputs +func ParseWithInputs(raw string) (*ParsedCommand, map[string]string, error) { + cmd, err := Parse(raw) + if err != nil { + return nil, nil, err + } + inputs := map[string]string{} + if cmd.Module == "cf" { + // /cf dns list + if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "list" && len(cmd.Args) >= 3 { + inputs["zone_id"] = cmd.Args[2] + } + // /cf dns update [ttl] [proxied] + if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "update" && len(cmd.Args) >= 7 { + inputs["zone_id"] = cmd.Args[2] + inputs["record_id"] = cmd.Args[3] + inputs["type"] = cmd.Args[4] + inputs["name"] = cmd.Args[5] + inputs["content"] = cmd.Args[6] + if len(cmd.Args) >= 8 { + inputs["ttl"] = cmd.Args[7] + } + if len(cmd.Args) >= 9 { + inputs["proxied"] = cmd.Args[8] + } + } + } + return cmd, inputs, nil +} diff --git a/internal/core/ecode/codes.go b/internal/core/ecode/codes.go new file mode 100644 index 0000000..9c1017f --- /dev/null +++ b/internal/core/ecode/codes.go @@ -0,0 +1,17 @@ +package ecode + +const ( + ErrPermissionDenied = "ERR_PERMISSION_DENIED" + ErrConfirmRequired = "ERR_CONFIRM_REQUIRED" + ErrFeatureDisabled = "ERR_FEATURE_DISABLED" + ErrStepFailed = "ERR_STEP_FAILED" + ErrJobCancelled = "ERR_JOB_CANCELLED" + ErrStepTimeout = "ERR_STEP_TIMEOUT" +) + +func Tag(code, msg string) string { + if code == "" { + return msg + } + return "[" + code + "] " + msg +} diff --git a/internal/core/module/request.go b/internal/core/module/request.go new file mode 100644 index 0000000..0837e0b --- /dev/null +++ b/internal/core/module/request.go @@ -0,0 +1,19 @@ +package module + +import "ops-assistant/internal/core/runbook" + +type Gate struct { + NeedFlag string + RequireConfirm bool + ExpectedToken string + AllowDryRun bool +} + +type Request struct { + RunbookName string + Inputs map[string]string + Meta runbook.RunMeta + Gate Gate + DryRun bool + ConfirmToken string +} diff --git a/internal/core/module/runner.go b/internal/core/module/runner.go new file mode 100644 index 0000000..2454f61 --- /dev/null +++ b/internal/core/module/runner.go @@ -0,0 +1,48 @@ +package module + +import ( + "fmt" + "strings" + + "gorm.io/gorm" + + "ops-assistant/internal/core/ecode" + "ops-assistant/internal/core/policy" + "ops-assistant/internal/core/runbook" +) + +type Runner struct { + db *gorm.DB + exec *runbook.Executor +} + +func NewRunner(db *gorm.DB, exec *runbook.Executor) *Runner { + return &Runner{db: db, exec: exec} +} + +func (r *Runner) Run(commandText string, operator int64, req Request) (uint, string, error) { + if strings.TrimSpace(req.RunbookName) == "" { + return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "runbook 不能为空")) + } + if req.DryRun { + if !req.Gate.AllowDryRun { + return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "当前命令不允许 dry-run")) + } + return 0, "dry-run", nil + } + if err := policy.CheckGate(r.db, policy.GateRequest{ + NeedFlag: req.Gate.NeedFlag, + RequireConfirm: req.Gate.RequireConfirm, + ConfirmToken: req.ConfirmToken, + ExpectedToken: req.Gate.ExpectedToken, + AllowDryRun: req.Gate.AllowDryRun, + DryRun: req.DryRun, + }); err != nil { + code := ecode.ErrFeatureDisabled + if strings.Contains(err.Error(), "confirm") || strings.Contains(err.Error(), "确认") { + code = ecode.ErrConfirmRequired + } + return 0, "", fmt.Errorf(ecode.Tag(code, err.Error())) + } + return r.exec.RunWithInputsAndMeta(commandText, req.RunbookName, operator, req.Inputs, req.Meta) +} diff --git a/internal/core/module/switches.go b/internal/core/module/switches.go new file mode 100644 index 0000000..c41ea96 --- /dev/null +++ b/internal/core/module/switches.go @@ -0,0 +1,26 @@ +package module + +import ( + "fmt" + "strings" + + "gorm.io/gorm" + + "ops-assistant/internal/core/policy" +) + +func switchFlag(module string) string { + module = strings.TrimSpace(strings.ToLower(module)) + if module == "" { + return "" + } + return fmt.Sprintf("enable_module_%s", module) +} + +func IsEnabled(db *gorm.DB, module string) bool { + k := switchFlag(module) + if k == "" { + return false + } + return policy.FlagEnabled(db, k) +} diff --git a/internal/core/module/template.go b/internal/core/module/template.go new file mode 100644 index 0000000..bad5dfe --- /dev/null +++ b/internal/core/module/template.go @@ -0,0 +1,97 @@ +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 +} + +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) + return jobID, out, err +} + +func FormatDryRunMessage(tpl CommandTemplate) string { + if tpl.DryRunMsg != "" { + return tpl.DryRunMsg + } + return fmt.Sprintf("🧪 dry-run: 将执行 %s(未实际执行)", tpl.RunbookName) +} + +func FormatSuccessMessage(tpl CommandTemplate, jobID uint) string { + if tpl.SuccessMsg != nil { + return tpl.SuccessMsg(jobID) + } + return fmt.Sprintf("✅ %s 已执行,job=%d", tpl.RunbookName, jobID) +} + +func MatchAnyPrefix(text string, prefixes []string) bool { + text = strings.TrimSpace(text) + for _, p := range prefixes { + if strings.HasPrefix(text, p) { + return true + } + } + return false +} + +func MatchCommand(text string, specs []CommandSpec) (CommandSpec, bool) { + for _, sp := range specs { + if MatchAnyPrefix(text, sp.Prefixes) { + return sp, true + } + } + return CommandSpec{}, false +} + +func FormatExecError(sp CommandSpec, err error) string { + msg := sp.ErrPrefix + err.Error() + if sp.ErrHint != "" { + msg += "(示例:" + sp.ErrHint + ")" + } + return msg +} diff --git a/internal/core/ops/bootstrap.go b/internal/core/ops/bootstrap.go new file mode 100644 index 0000000..3151048 --- /dev/null +++ b/internal/core/ops/bootstrap.go @@ -0,0 +1,26 @@ +package ops + +import ( + "path/filepath" + + "ops-assistant/internal/core/registry" + "ops-assistant/internal/core/runbook" + "ops-assistant/internal/module/cf" + "ops-assistant/internal/module/cpa" + "ops-assistant/internal/module/mail" + + "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) + + r.RegisterModule("cpa", cpaModule.Handle) + r.RegisterModule("cf", cfModule.Handle) + r.RegisterModule("mail", mailModule.Handle) + return NewService(dbPath, baseDir, r) +} diff --git a/internal/core/ops/retry.go b/internal/core/ops/retry.go new file mode 100644 index 0000000..d2b2784 --- /dev/null +++ b/internal/core/ops/retry.go @@ -0,0 +1,60 @@ +package ops + +import ( + "encoding/json" + "errors" + "path/filepath" + "strings" + + "ops-assistant/internal/core/runbook" + "ops-assistant/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func decodeInputJSON(raw string, out *map[string]string) error { + if strings.TrimSpace(raw) == "" { + return nil + } + return json.Unmarshal([]byte(raw), out) +} + +func RetryJobWithDB(db *gorm.DB, baseDir string, jobID uint) (uint, error) { + if db == nil { + return 0, errors.New("db is nil") + } + var old models.OpsJob + if err := db.First(&old, jobID).Error; err != nil { + return 0, err + } + if strings.TrimSpace(old.Status) != "failed" { + return 0, errors.New("only failed jobs can retry") + } + + inputs := map[string]string{} + if strings.TrimSpace(old.InputJSON) != "" { + _ = decodeInputJSON(old.InputJSON, &inputs) + } + + meta := runbook.NewMeta() + meta.Target = old.Target + meta.RiskLevel = old.RiskLevel + meta.RequestID = old.RequestID + "-retry" + meta.ConfirmHash = old.ConfirmHash + + exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks")) + newID, _, err := exec.RunWithInputsAndMeta(old.Command, old.Runbook, old.Operator, inputs, meta) + if err != nil { + return newID, err + } + return newID, nil +} + +func RetryJob(dbPath, baseDir string, jobID uint) (uint, error) { + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return 0, err + } + return RetryJobWithDB(db, baseDir, jobID) +} diff --git a/internal/core/ops/run_once.go b/internal/core/ops/run_once.go new file mode 100644 index 0000000..7f5c925 --- /dev/null +++ b/internal/core/ops/run_once.go @@ -0,0 +1,20 @@ +package ops + +import ( + "path/filepath" + + "ops-assistant/internal/core/runbook" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// RunOnce executes a runbook directly without bot/channel. +func RunOnce(dbPath, baseDir, commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) { + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + if err != nil { + return 0, "", err + } + exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks")) + return exec.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, runbook.NewMeta()) +} diff --git a/internal/core/ops/service.go b/internal/core/ops/service.go new file mode 100644 index 0000000..7f5a219 --- /dev/null +++ b/internal/core/ops/service.go @@ -0,0 +1,100 @@ +package ops + +import ( + "fmt" + "strings" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "ops-assistant/internal/core/command" + coremodule "ops-assistant/internal/core/module" + "ops-assistant/internal/core/registry" +) + +type Service struct { + dbPath string + baseDir string + registry *registry.Registry + db *gorm.DB +} + +func NewService(dbPath, baseDir string, reg *registry.Registry) *Service { + db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + return &Service{dbPath: dbPath, baseDir: baseDir, registry: reg, db: db} +} + +func (s *Service) Handle(userID int64, text string) (bool, string) { + if !strings.HasPrefix(strings.TrimSpace(text), "/") { + return false, "" + } + cmd, _, err := command.ParseWithInputs(text) + if err != nil { + return false, "" + } + // 通用帮助 + if cmd.Module == "help" || cmd.Name == "/help" || cmd.Name == "/start" { + return true, s.helpText() + } + if cmd.Module == "ops" && (len(cmd.Args) == 0 || cmd.Args[0] == "help") { + return true, s.helpText() + } + if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" { + return true, s.modulesStatusText() + } + if cmd.Module != "" && cmd.Module != "ops" && s.db != nil { + if !coremodule.IsEnabled(s.db, cmd.Module) { + return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s(开关: enable_module_%s)", cmd.Module, cmd.Module) + } + } + out, handled, err := s.registry.Handle(userID, cmd) + if !handled { + return false, "" + } + if err != nil { + return true, "❌ OPS 执行失败: " + err.Error() + } + return true, out +} + +func (s *Service) helpText() string { + lines := []string{ + "🛠️ OPS 交互命令:", + "- /ops modules (查看模块启用状态)", + "- /cpa help", + "- /cpa status", + "- /cpa usage backup", + "- /cpa usage restore [--confirm YES_RESTORE] [--dry-run]", + "- /cf status (需要 enable_module_cf)", + "- /cf zones (需要 enable_module_cf)", + "- /cf dns list (需要 enable_module_cf)", + "- /cf dns update [ttl] [proxied:true|false] (需要 enable_module_cf)", + "- /cf dnsadd [on|off] [type] (需要 enable_module_cf)", + "- /cf dnsset [true] (需要 enable_module_cf)", + "- /cf dnsdel YES (需要 enable_module_cf)", + "- /cf dnsproxy on|off (需要 enable_module_cf)", + "- /cf workers list (需要 enable_module_cf)", + "- /mail status (需要 enable_module_mail)", + } + return strings.Join(lines, "\n") +} + +func (s *Service) modulesStatusText() string { + mods := s.registry.ListModules() + if len(mods) == 0 { + return "暂无已注册模块" + } + lines := []string{"🧩 模块状态:"} + for _, m := range mods { + enabled := false + if s.db != nil { + enabled = coremodule.IsEnabled(s.db, m) + } + state := "disabled" + if enabled { + state = "enabled" + } + lines = append(lines, fmt.Sprintf("- %s: %s", m, state)) + } + lines = append(lines, "\n可用命令:/ops modules") + return strings.Join(lines, "\n") +} diff --git a/internal/core/policy/policy.go b/internal/core/policy/policy.go new file mode 100644 index 0000000..97bb2df --- /dev/null +++ b/internal/core/policy/policy.go @@ -0,0 +1,62 @@ +package policy + +import ( + "errors" + "strings" + + "gorm.io/gorm" + + "ops-assistant/models" +) + +type GateRequest struct { + NeedFlag string + RequireConfirm bool + ConfirmToken string + ExpectedToken string + AllowDryRun bool + DryRun bool +} + +func ParseCommonFlags(text string) (dryRun bool, confirmToken string) { + parts := strings.Fields(strings.TrimSpace(text)) + for i := 0; i < len(parts); i++ { + if parts[i] == "--dry-run" { + dryRun = true + } + if parts[i] == "--confirm" && i+1 < len(parts) { + confirmToken = strings.TrimSpace(parts[i+1]) + i++ + } + } + return +} + +func FlagEnabled(db *gorm.DB, key string) bool { + if strings.TrimSpace(key) == "" { + return true + } + var ff models.FeatureFlag + if err := db.Where("key = ?", key).First(&ff).Error; err != nil { + return false + } + return ff.Enabled +} + +func CheckGate(db *gorm.DB, req GateRequest) error { + if strings.TrimSpace(req.NeedFlag) != "" && !FlagEnabled(db, req.NeedFlag) { + return errors.New("feature flag 未启用: " + req.NeedFlag) + } + if req.RequireConfirm { + if strings.TrimSpace(req.ConfirmToken) == "" { + return errors.New("缺少 --confirm ") + } + if strings.TrimSpace(req.ExpectedToken) != "" && strings.TrimSpace(req.ConfirmToken) != strings.TrimSpace(req.ExpectedToken) { + return errors.New("确认 token 无效") + } + } + if req.DryRun && !req.AllowDryRun { + return errors.New("当前命令不允许 dry-run") + } + return nil +} diff --git a/internal/core/ports/channel.go b/internal/core/ports/channel.go new file mode 100644 index 0000000..c6de0cd --- /dev/null +++ b/internal/core/ports/channel.go @@ -0,0 +1,14 @@ +package ports + +type UnifiedMessage struct { + Channel string + OperatorID int64 + Text string + RawID string +} + +type ChannelAdapter interface { + Name() string + Normalize(any) (*UnifiedMessage, error) + Reply(targetID string, text string) error +} diff --git a/internal/core/ports/module.go b/internal/core/ports/module.go new file mode 100644 index 0000000..810e4b9 --- /dev/null +++ b/internal/core/ports/module.go @@ -0,0 +1,6 @@ +package ports + +type Module interface { + Name() string + CommandPrefix() string +} diff --git a/internal/core/registry/registry.go b/internal/core/registry/registry.go new file mode 100644 index 0000000..7d28bc3 --- /dev/null +++ b/internal/core/registry/registry.go @@ -0,0 +1,47 @@ +package registry + +import ( + "sort" + + "ops-assistant/internal/core/command" +) + +type Handler func(userID int64, cmd *command.ParsedCommand) (string, error) + +type Registry struct { + handlers map[string]Handler + moduleHandlers map[string]Handler +} + +func New() *Registry { + return &Registry{handlers: map[string]Handler{}, moduleHandlers: map[string]Handler{}} +} + +func (r *Registry) Register(name string, h Handler) { + r.handlers[name] = h +} + +func (r *Registry) RegisterModule(module string, h Handler) { + r.moduleHandlers[module] = h +} + +func (r *Registry) Handle(userID int64, cmd *command.ParsedCommand) (string, bool, error) { + if h, ok := r.handlers[cmd.Name]; ok { + out, err := h(userID, cmd) + return out, true, err + } + if h, ok := r.moduleHandlers[cmd.Module]; ok { + out, err := h(userID, cmd) + return out, true, err + } + return "", false, nil +} + +func (r *Registry) ListModules() []string { + mods := make([]string, 0, len(r.moduleHandlers)) + for m := range r.moduleHandlers { + mods = append(mods, m) + } + sort.Strings(mods) + return mods +} diff --git a/internal/core/runbook/cancel.go b/internal/core/runbook/cancel.go new file mode 100644 index 0000000..7fd4320 --- /dev/null +++ b/internal/core/runbook/cancel.go @@ -0,0 +1,32 @@ +package runbook + +import ( + "context" + "sync" +) + +var jobCancelMap sync.Map + +func registerJobCancel(jobID uint, cancel context.CancelFunc) { + jobCancelMap.Store(jobID, cancel) +} + +func clearJobCancel(jobID uint) { + if v, ok := jobCancelMap.Load(jobID); ok { + if cancel, ok2 := v.(context.CancelFunc); ok2 { + cancel() + } + jobCancelMap.Delete(jobID) + } +} + +func CancelJob(jobID uint) bool { + if v, ok := jobCancelMap.Load(jobID); ok { + if cancel, ok2 := v.(context.CancelFunc); ok2 { + cancel() + } + jobCancelMap.Delete(jobID) + return true + } + return false +} diff --git a/internal/core/runbook/executor.go b/internal/core/runbook/executor.go new file mode 100644 index 0000000..b47742b --- /dev/null +++ b/internal/core/runbook/executor.go @@ -0,0 +1,387 @@ +package runbook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "ops-assistant/internal/core/ecode" + "ops-assistant/models" + + "gorm.io/gorm" +) + +type Executor struct { + db *gorm.DB + runbookDir string +} + +func NewExecutor(db *gorm.DB, runbookDir string) *Executor { + return &Executor{db: db, runbookDir: runbookDir} +} + +func (e *Executor) Run(commandText, runbookName string, operator int64) (uint, string, error) { + return e.RunWithInputsAndMeta(commandText, runbookName, operator, map[string]string{}, NewMeta()) +} + +func (e *Executor) RunWithInputs(commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) { + return e.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, NewMeta()) +} + +func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operator int64, inputs map[string]string, meta RunMeta) (uint, string, error) { + started := time.Now() + inputJSON := "{}" + if b, err := json.Marshal(inputs); err == nil { + inputJSON = string(b) + } + job := models.OpsJob{ + Command: commandText, + Runbook: runbookName, + Operator: operator, + Target: strings.TrimSpace(meta.Target), + RiskLevel: strings.TrimSpace(meta.RiskLevel), + RequestID: strings.TrimSpace(meta.RequestID), + ConfirmHash: strings.TrimSpace(meta.ConfirmHash), + InputJSON: inputJSON, + Status: "pending", + StartedAt: started, + } + if job.RiskLevel == "" { + job.RiskLevel = "low" + } + if err := e.db.Create(&job).Error; err != nil { + return 0, "", err + } + + release := acquireTargetLock(job.Target) + defer release() + + job.Status = "running" + _ = e.db.Save(&job).Error + + specPath := filepath.Join(e.runbookDir, runbookName+".yaml") + data, err := os.ReadFile(specPath) + if err != nil { + e.finishJob(&job, "failed", "runbook not found") + return job.ID, "", err + } + spec, err := Parse(data) + if err != nil { + e.finishJob(&job, "failed", "runbook parse failed") + return job.ID, "", err + } + + outputs := map[string]string{} + ctx := map[string]string{} + + jobCtx, jobCancel := context.WithCancel(context.Background()) + registerJobCancel(job.ID, jobCancel) + defer clearJobCancel(job.ID) + for k, v := range inputs { + ctx["inputs."+k] = v + } + if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_BASE")); t != "" { + ctx["env.cpa_management_base"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cpa_management_base").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env.cpa_management_base"] = strings.TrimSpace(sset.Value) + } + } + } + if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_TOKEN")); t != "" { + ctx["env.cpa_management_token"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cpa_management_token").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env.cpa_management_token"] = strings.TrimSpace(sset.Value) + } + } + } + // Cloudflare settings + if t := strings.TrimSpace(os.Getenv("CF_ACCOUNT_ID")); t != "" { + ctx["env_cf_account_id"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cf_account_id").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env_cf_account_id"] = strings.TrimSpace(sset.Value) + } + } + } + if t := strings.TrimSpace(os.Getenv("CF_API_EMAIL")); t != "" { + ctx["env_cf_api_email"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cf_api_email").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env_cf_api_email"] = strings.TrimSpace(sset.Value) + } + } + } + if t := strings.TrimSpace(os.Getenv("CF_API_TOKEN")); t != "" { + ctx["env_cf_api_token"] = t + } else { + var sset models.AppSetting + if err := e.db.Where("key = ?", "cf_api_token").First(&sset).Error; err == nil { + if strings.TrimSpace(sset.Value) != "" { + ctx["env_cf_api_token"] = strings.TrimSpace(sset.Value) + } + } + } + + // inject input env vars for runbook steps + for k, v := range inputs { + if strings.TrimSpace(v) != "" { + ctx["env.INPUT_"+strings.ToUpper(k)] = v + } + } + for _, st := range spec.Steps { + if isJobCancelled(e.db, job.ID) { + e.finishJob(&job, "cancelled", ecode.Tag(ecode.ErrJobCancelled, "cancelled by user")) + return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrJobCancelled, "cancelled by user")) + } + + rendered := renderStep(st, ctx) + step := models.OpsJobStep{JobID: job.ID, StepID: rendered.ID, Action: rendered.Action, Status: "running", StartedAt: time.Now()} + _ = e.db.Create(&step).Error + + timeout := meta.timeoutOrDefault() + rc, stdout, stderr, runErr := e.execStep(jobCtx, rendered, outputs, timeout) + step.RC = rc + step.StdoutTail = tail(stdout, 1200) + step.StderrTail = tail(stderr, 1200) + step.EndedAt = time.Now() + if runErr != nil || rc != 0 { + step.Status = "failed" + _ = e.db.Save(&step).Error + e.finishJob(&job, "failed", fmt.Sprintf("%s: step=%s failed", ecode.ErrStepFailed, rendered.ID)) + if runErr == nil { + runErr = fmt.Errorf("rc=%d", rc) + } + return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("step %s failed: %v", rendered.ID, runErr))) + } + step.Status = "success" + _ = e.db.Save(&step).Error + outputs[rendered.ID] = stdout + ctx["steps."+rendered.ID+".output"] = stdout + } + + e.finishJob(&job, "success", "ok") + return job.ID, "ok", nil +} + +func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) { + switch st.Action { + case "ssh.exec": + target := strings.TrimSpace(fmt.Sprintf("%v", st.With["target"])) + cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"])) + if target == "" || cmdText == "" { + return 1, "", "missing target/command", fmt.Errorf("missing target/command") + } + resolved := resolveTarget(e.db, target) + if !resolved.Found { + return 1, "", "invalid target", fmt.Errorf("invalid target: %s", target) + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, cmdText} + cmd := exec.CommandContext(ctx, "ssh", args...) + var outb, errb bytes.Buffer + cmd.Stdout = &outb + cmd.Stderr = &errb + err := cmd.Run() + rc := 0 + if err != nil { + rc = 1 + if ctx.Err() == context.DeadlineExceeded { + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "ssh step timeout")) + } + } + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err + + case "shell.exec": + cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"])) + if cmdText == "" { + return 1, "", "missing command", fmt.Errorf("missing command") + } + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-lc", cmdText) + var outb, errb bytes.Buffer + cmd.Stdout = &outb + cmd.Stderr = &errb + err := cmd.Run() + rc := 0 + if err != nil { + rc = 1 + if ctx.Err() == context.DeadlineExceeded { + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "shell step timeout")) + } + } + return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err + + case "assert.json": + sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"])) + if sourceStep == "" { + return 1, "", "missing source_step", fmt.Errorf("missing source_step") + } + raw, ok := outputs[sourceStep] + if !ok { + return 1, "", "source step output not found", fmt.Errorf("source step output not found: %s", sourceStep) + } + + var payload any + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + 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") + } + 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) + } + } + return 0, "assert ok", "", nil + + case "sleep": + ms := 1000 + if v, ok := st.With["ms"]; ok { + switch t := v.(type) { + case int: + ms = t + case int64: + ms = int(t) + case float64: + ms = int(t) + case string: + if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { + ms = n + } + } + } + if ms < 0 { + ms = 0 + } + time.Sleep(time.Duration(ms) * time.Millisecond) + return 0, fmt.Sprintf("slept %dms", ms), "", nil + + default: + return 1, "", "unsupported action", fmt.Errorf("unsupported action: %s", st.Action) + } +} + +func renderStep(st Step, ctx map[string]string) Step { + out := st + out.ID = renderString(out.ID, ctx) + out.Action = renderString(out.Action, ctx) + if out.With == nil { + return out + } + 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 + } + } + out.With = m + return out +} + +func renderString(s string, ctx map[string]string) string { + res := s + for k, v := range ctx { + res = strings.ReplaceAll(res, "${"+k+"}", v) + } + return res +} + +func parseRequiredPaths(v any) []string { + res := []string{} + switch t := v.(type) { + case []any: + for _, it := range t { + res = append(res, strings.TrimSpace(fmt.Sprintf("%v", it))) + } + case []string: + for _, it := range t { + res = append(res, strings.TrimSpace(it)) + } + } + out := make([]string, 0, len(res)) + for _, p := range res { + if p != "" { + out = append(out, p) + } + } + return out +} + +func lookupPath(root any, path string) (any, bool) { + parts := strings.Split(path, ".") + cur := root + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return nil, false + } + m, ok := cur.(map[string]any) + if !ok { + return nil, false + } + next, exists := m[part] + if !exists { + return nil, false + } + cur = next + } + return cur, true +} + +func (e *Executor) finishJob(job *models.OpsJob, status, summary string) { + job.Status = status + job.Summary = summary + job.EndedAt = time.Now() + _ = e.db.Save(job).Error +} + +func isJobCancelled(db *gorm.DB, jobID uint) bool { + var j models.OpsJob + if err := db.Select("status").First(&j, jobID).Error; err != nil { + return false + } + return strings.EqualFold(strings.TrimSpace(j.Status), "cancelled") +} + +func tail(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[len(s)-max:] +} diff --git a/internal/core/runbook/lock.go b/internal/core/runbook/lock.go new file mode 100644 index 0000000..4556681 --- /dev/null +++ b/internal/core/runbook/lock.go @@ -0,0 +1,21 @@ +package runbook + +import ( + "sync" +) + +var globalTargetLocks sync.Map + +type targetLock struct { + mu sync.Mutex +} + +func acquireTargetLock(target string) func() { + if target == "" { + return func() {} + } + v, _ := globalTargetLocks.LoadOrStore(target, &targetLock{}) + lk := v.(*targetLock) + lk.mu.Lock() + return func() { lk.mu.Unlock() } +} diff --git a/internal/core/runbook/meta.go b/internal/core/runbook/meta.go new file mode 100644 index 0000000..d502cb5 --- /dev/null +++ b/internal/core/runbook/meta.go @@ -0,0 +1,23 @@ +package runbook + +import "time" + +type RunMeta struct { + Target string + RiskLevel string + RequestID string + ConfirmHash string + StepTimeoutMs int +} + +func NewMeta() RunMeta { + return RunMeta{RiskLevel: "low"} +} + +func (m RunMeta) timeoutOrDefault() time.Duration { + ms := m.StepTimeoutMs + if ms <= 0 { + ms = 45000 + } + return time.Duration(ms) * time.Millisecond +} diff --git a/internal/core/runbook/seed_targets.go b/internal/core/runbook/seed_targets.go new file mode 100644 index 0000000..2618ee7 --- /dev/null +++ b/internal/core/runbook/seed_targets.go @@ -0,0 +1,20 @@ +package runbook + +import ( + "ops-assistant/models" + + "gorm.io/gorm" +) + +func SeedDefaultTargets(db *gorm.DB) error { + defaults := []models.OpsTarget{ + {Name: "hwsg", Host: "10.2.3.11", Port: 22, User: "root", Enabled: true}, + {Name: "wjynl", Host: "66.235.105.208", Port: 22, User: "root", Enabled: true}, + } + for _, t := range defaults { + if err := db.Where("name = ?", t.Name).FirstOrCreate(&t).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/core/runbook/targets.go b/internal/core/runbook/targets.go new file mode 100644 index 0000000..ac114e7 --- /dev/null +++ b/internal/core/runbook/targets.go @@ -0,0 +1,37 @@ +package runbook + +import ( + "strings" + + "ops-assistant/models" + + "gorm.io/gorm" +) + +type ResolvedTarget struct { + Found bool + User string + Host string + Port int +} + +func resolveTarget(db *gorm.DB, name string) ResolvedTarget { + trim := strings.TrimSpace(name) + if trim == "" { + return ResolvedTarget{} + } + var t models.OpsTarget + if err := db.Where("name = ? AND enabled = ?", trim, true).First(&t).Error; err != nil { + return ResolvedTarget{} + } + user := strings.TrimSpace(t.User) + host := strings.TrimSpace(t.Host) + port := t.Port + if user == "" || host == "" { + return ResolvedTarget{} + } + if port <= 0 { + port = 22 + } + return ResolvedTarget{Found: true, User: user, Host: host, Port: port} +} diff --git a/internal/core/runbook/types.go b/internal/core/runbook/types.go new file mode 100644 index 0000000..6f8f3d0 --- /dev/null +++ b/internal/core/runbook/types.go @@ -0,0 +1,24 @@ +package runbook + +import "gopkg.in/yaml.v3" + +type Spec struct { + Version int `yaml:"version"` + Name string `yaml:"name"` + Steps []Step `yaml:"steps"` +} + +type Step struct { + ID string `yaml:"id"` + Action string `yaml:"action"` + OnFail string `yaml:"on_fail"` + With map[string]any `yaml:"with"` +} + +func Parse(data []byte) (*Spec, error) { + var s Spec + if err := yaml.Unmarshal(data, &s); err != nil { + return nil, err + } + return &s, nil +} diff --git a/internal/feishu/feishu.go b/internal/feishu/feishu.go new file mode 100644 index 0000000..eb28a34 --- /dev/null +++ b/internal/feishu/feishu.go @@ -0,0 +1,138 @@ +package feishu + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "ops-assistant/internal/channel" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/service" + "ops-assistant/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// DefaultUserID 统一用户ID,使所有平台共享同一份账本 +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, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot { + return &Bot{ + db: db, + finance: finance, + opsSvc: opsSvc, + appID: appID, + appSecret: appSecret, + verificationToken: verificationToken, + encryptKey: encryptKey, + } +} + +func (b *Bot) Start(ctx context.Context) { + log.Printf("🚀 Feishu Bot 已启用 app_id=%s", maskID(b.appID)) + <-ctx.Done() + log.Printf("⏳ Feishu Bot 已停止") +} + +func (b *Bot) RegisterRoutes(r *gin.Engine) { + r.POST("/webhook/feishu", b.handleWebhook) +} + +func (b *Bot) handleWebhook(c *gin.Context) { + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"}) + return + } + + // 统一走 channel 包解析,便于后续扩展验签/解密 + msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if verifyChallenge != "" { + c.JSON(http.StatusOK, gin.H{"challenge": verifyChallenge}) + return + } + if msg == nil { + c.JSON(http.StatusOK, gin.H{"code": 0}) + return + } + + // 幂等去重 + var existed models.MessageDedup + if err := b.db.Where("platform = ? AND event_id = ?", "feishu", msg.EventID).First(&existed).Error; err == nil { + c.JSON(http.StatusOK, gin.H{"code": 0}) + return + } + _ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error + + reply := b.handleText(msg.Text) + if reply != "" && msg.UserID != "" { + tk, err := channel.GetFeishuTenantToken(c.Request.Context(), b.appID, b.appSecret) + if err == nil { + _ = channel.SendFeishuText(c.Request.Context(), tk, msg.UserID, reply) + } + } + + c.JSON(http.StatusOK, gin.H{"code": 0}) +} + +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 "🛠️ Ops-Assistant\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计" + case "查看", "记录", "列表", "最近": + items, err := b.finance.GetTransactions(DefaultUserID, 10) + if err != nil { + return "❌ 查询失败" + } + if len(items) == 0 { + return "📭 暂无记录" + } + var sb strings.Builder + sb.WriteString("📋 最近记录:\n\n") + for _, item := range items { + sb.WriteString(fmt.Sprintf("%s %s %.2f元\n", item.Date, item.Category, item.AmountYuan())) + } + return sb.String() + } + + amount, category, err := b.finance.AddTransaction(DefaultUserID, trim) + if err != nil { + return "❌ 记账失败,请稍后重试" + } + if amount == 0 { + return "📍 没看到金额,这笔花了多少钱?" + } + return fmt.Sprintf("✅ 已记入【%s】:%.2f元\n📝 备注:%s", category, float64(amount)/100.0, trim) +} + +func maskID(s string) string { + if len(s) <= 6 { + return "***" + } + return s[:3] + "***" + s[len(s)-3:] +} diff --git a/internal/module/cf/commands.go b/internal/module/cf/commands.go new file mode 100644 index 0000000..fbab11a --- /dev/null +++ b/internal/module/cf/commands.go @@ -0,0 +1,254 @@ +package cf + +import ( + "fmt" + "strings" + + "ops-assistant/internal/core/ecode" + coremodule "ops-assistant/internal/core/module" +) + +func commandSpecs() []coremodule.CommandSpec { + return []coremodule.CommandSpec{ + { + Prefixes: []string{"/cf status"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_status", + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf status(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf status 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf status 执行失败: ", + }, + { + Prefixes: []string{"/cf zones"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_zones", + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf zones(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf zones 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf zones 执行失败: ", + }, + { + Prefixes: []string{"/cf dns list"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_dns_list", + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 4 { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns list ")) + } + return map[string]string{"zone_id": parts[3]}, nil + }, + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf dns list (未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf dns list 执行失败: ", + ErrHint: "/cf dns list ", + }, + { + Prefixes: []string{"/cf dns update"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_dns_update", + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 8 { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns update [ttl] [proxied]")) + } + inputs := map[string]string{ + "zone_id": parts[3], + "record_id": parts[4], + "type": parts[5], + "name": parts[6], + "content": parts[7], + } + if len(parts) >= 9 { + inputs["ttl"] = parts[8] + } + if len(parts) >= 10 { + inputs["proxied"] = parts[9] + } + return inputs, nil + }, + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf dns update(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns update 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf dns update 执行失败: ", + ErrHint: "/cf dns update [ttl] [proxied:true|false]", + }, + { + Prefixes: []string{"/cf dnsadd"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_dns_add", + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 4 { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsadd [on|off] [type]")) + } + name := parts[2] + content := parts[3] + proxied := "false" + recType := "A" + if len(parts) >= 5 { + switch strings.ToLower(parts[4]) { + case "on": + proxied = "true" + if len(parts) >= 6 { + recType = parts[5] + } + case "off": + proxied = "false" + if len(parts) >= 6 { + recType = parts[5] + } + case "true": + proxied = "true" + if len(parts) >= 6 { + recType = parts[5] + } + case "false": + proxied = "false" + if len(parts) >= 6 { + recType = parts[5] + } + default: + // treat as type when no on/off provided + recType = parts[4] + } + } + inputs := map[string]string{ + "name": name, + "content": content, + "type": strings.ToUpper(recType), + "proxied": strings.ToLower(proxied), + } + return inputs, nil + }, + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf dnsadd(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsadd 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf dnsadd 执行失败: ", + ErrHint: "/cf dnsadd [on|off] [type]", + }, + { + Prefixes: []string{"/cf dnsset"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_dns_set", + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 4 { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsset [true]")) + } + proxied := "false" + if len(parts) >= 5 && strings.EqualFold(parts[4], "true") { + proxied = "true" + } + return map[string]string{ + "record_id": parts[2], + "content": parts[3], + "proxied": strings.ToLower(proxied), + }, nil + }, + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf dnsset(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsset 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf dnsset 执行失败: ", + ErrHint: "/cf dnsset [true]", + }, + { + Prefixes: []string{"/cf dnsdel"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_dns_del", + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 4 { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsdel YES")) + } + if len(parts) < 4 || !strings.EqualFold(parts[3], "YES") { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "缺少确认词 YES,示例:/cf dnsdel YES")) + } + return map[string]string{ + "record_id": parts[2], + }, nil + }, + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: false, + }, + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsdel 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf dnsdel 执行失败: ", + ErrHint: "/cf dnsdel YES", + }, + { + Prefixes: []string{"/cf dnsproxy"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_dns_proxy", + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 4 { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dnsproxy on|off")) + } + mode := strings.ToLower(parts[3]) + if mode != "on" && mode != "off" { + return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数无效,示例:/cf dnsproxy on|off")) + } + proxied := "false" + if mode == "on" { + proxied = "true" + } + inputs := map[string]string{ + "proxied": proxied, + "record_id": "__empty__", + "name": "__empty__", + } + target := parts[2] + if strings.Contains(target, ".") { + inputs["name"] = target + } else { + inputs["record_id"] = target + } + return inputs, nil + }, + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf dnsproxy(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dnsproxy 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf dnsproxy 执行失败: ", + ErrHint: "/cf dnsproxy on|off", + }, + { + Prefixes: []string{"/cf workers list"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cf_workers_list", + Gate: coremodule.Gate{ + NeedFlag: "enable_module_cf", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /cf workers list(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf workers list 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cf workers list 执行失败: ", + }, + } +} diff --git a/internal/module/cf/module.go b/internal/module/cf/module.go new file mode 100644 index 0000000..332af4b --- /dev/null +++ b/internal/module/cf/module.go @@ -0,0 +1,40 @@ +package cf + +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 == "/cf" || strings.HasPrefix(text, "/cf help") { + return "CF 模块\n- /cf status [--dry-run]\n- /cf zones\n- /cf dns list \n- /cf dns update [ttl] [proxied:true|false]\n- /cf dnsadd [on|off] [type]\n- /cf dnsset [true]\n- /cf dnsdel YES\n- /cf dnsproxy on|off\n- /cf workers list", 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, "CF 模块已接入,当前支持:/cf status, /cf help"), nil +} diff --git a/internal/module/cpa/commands.go b/internal/module/cpa/commands.go new file mode 100644 index 0000000..d157508 --- /dev/null +++ b/internal/module/cpa/commands.go @@ -0,0 +1,62 @@ +package cpa + +import ( + "fmt" + "strings" + + coremodule "ops-assistant/internal/core/module" + "ops-assistant/internal/core/runbook" +) + +func commandSpecs() []coremodule.CommandSpec { + return []coremodule.CommandSpec{ + { + Prefixes: []string{"/cpa status"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cpa_status", + Gate: coremodule.Gate{AllowDryRun: true}, + DryRunMsg: "🧪 dry-run: 将执行 /cpa status(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa status 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cpa status 执行失败: ", + }, + { + Prefixes: []string{"/cpa usage backup"}, + Template: coremodule.CommandTemplate{ + RunbookName: "cpa_usage_backup", + Gate: coremodule.Gate{AllowDryRun: true}, + DryRunMsg: "🧪 dry-run: 将执行 /cpa usage backup(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage backup 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cpa usage backup 执行失败: ", + }, + { + Prefixes: []string{"/cpa usage restore "}, + ErrHint: "--confirm YES_RESTORE", + Template: coremodule.CommandTemplate{ + RunbookName: "cpa_usage_restore", + Gate: coremodule.Gate{NeedFlag: "allow_ops_restore", RequireConfirm: true, ExpectedToken: "YES_RESTORE", AllowDryRun: true}, + InputsFn: func(_ string, parts []string) (map[string]string, error) { + if len(parts) < 4 { + return nil, fmt.Errorf("❌ 用法:/cpa usage restore ") + } + backupID := strings.TrimSpace(parts[3]) + if backupID == "" { + return nil, fmt.Errorf("❌ backup_id 不能为空") + } + return map[string]string{"backup_id": backupID}, nil + }, + MetaFn: func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta { + meta := runbook.NewMeta() + meta.Target = "hwsg" + meta.RiskLevel = "high" + meta.ConfirmHash = hashConfirmToken(confirmToken) + return meta + }, + DryRunMsg: "🧪 dry-run: 将执行 restore(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage restore 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/cpa usage restore 执行失败: ", + }, + } +} diff --git a/internal/module/cpa/crypto.go b/internal/module/cpa/crypto.go new file mode 100644 index 0000000..f082ed2 --- /dev/null +++ b/internal/module/cpa/crypto.go @@ -0,0 +1,16 @@ +package cpa + +import ( + "crypto/sha256" + "encoding/hex" + "strings" +) + +func hashConfirmToken(token string) string { + t := strings.TrimSpace(token) + if t == "" { + return "" + } + sum := sha256.Sum256([]byte(t)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/module/cpa/guards.go b/internal/module/cpa/guards.go new file mode 100644 index 0000000..0364d82 --- /dev/null +++ b/internal/module/cpa/guards.go @@ -0,0 +1,13 @@ +package cpa + +import ( + "ops-assistant/internal/core/ecode" +) + +func formatErr(code, msg string) string { + return ecode.Tag(code, msg) +} + +func formatOK(msg string) string { + return ecode.Tag("OK", msg) +} diff --git a/internal/module/cpa/module.go b/internal/module/cpa/module.go new file mode 100644 index 0000000..a6b66ed --- /dev/null +++ b/internal/module/cpa/module.go @@ -0,0 +1,40 @@ +package cpa + +import ( + "strings" + + "ops-assistant/internal/core/command" + "ops-assistant/internal/core/ecode" + coremodule "ops-assistant/internal/core/module" + "ops-assistant/internal/core/runbook" + "gorm.io/gorm" +) + +type Module struct { + db *gorm.DB + exec *runbook.Executor + runner *coremodule.Runner +} + +func New(db *gorm.DB, exec *runbook.Executor) *Module { + return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)} +} + +func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) { + text := strings.TrimSpace(cmd.Raw) + if text == "/cpa" || strings.HasPrefix(text, "/cpa help") { + return "CPA 模块\n- /cpa status\n- /cpa usage backup\n- /cpa usage restore [--confirm YES_RESTORE] [--dry-run]", nil + } + specs := commandSpecs() + if sp, ok := coremodule.MatchCommand(text, specs); ok { + jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template) + if err != nil { + return formatErr(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil + } + if out == "dry-run" { + return formatOK(coremodule.FormatDryRunMessage(sp.Template)), nil + } + return formatOK(coremodule.FormatSuccessMessage(sp.Template, jobID)), nil + } + return "❓ 暂不支持该 CPA 命令。当前支持:/cpa status, /cpa usage backup, /cpa usage restore ", nil +} diff --git a/internal/module/mail/commands.go b/internal/module/mail/commands.go new file mode 100644 index 0000000..f0f0d53 --- /dev/null +++ b/internal/module/mail/commands.go @@ -0,0 +1,25 @@ +package mail + +import ( + "fmt" + + coremodule "ops-assistant/internal/core/module" +) + +func commandSpecs() []coremodule.CommandSpec { + return []coremodule.CommandSpec{ + { + Prefixes: []string{"/mail status"}, + Template: coremodule.CommandTemplate{ + RunbookName: "mail_status", + Gate: coremodule.Gate{ + NeedFlag: "enable_module_mail", + AllowDryRun: true, + }, + DryRunMsg: "🧪 dry-run: 将执行 /mail status(未实际执行)", + SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /mail status 已执行,job=%d", jobID) }, + }, + ErrPrefix: "/mail status 执行失败: ", + }, + } +} diff --git a/internal/module/mail/module.go b/internal/module/mail/module.go new file mode 100644 index 0000000..cc28aa1 --- /dev/null +++ b/internal/module/mail/module.go @@ -0,0 +1,40 @@ +package mail + +import ( + "strings" + + "ops-assistant/internal/core/command" + "ops-assistant/internal/core/ecode" + coremodule "ops-assistant/internal/core/module" + "ops-assistant/internal/core/runbook" + "gorm.io/gorm" +) + +type Module struct { + db *gorm.DB + exec *runbook.Executor + runner *coremodule.Runner +} + +func New(db *gorm.DB, exec *runbook.Executor) *Module { + return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)} +} + +func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) { + text := strings.TrimSpace(cmd.Raw) + if text == "/mail" || strings.HasPrefix(text, "/mail help") { + return "Mail 模块\n- /mail status [--dry-run]", nil + } + specs := commandSpecs() + if sp, ok := coremodule.MatchCommand(text, specs); ok { + jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template) + if err != nil { + return ecode.Tag(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil + } + if out == "dry-run" { + return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil + } + return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil + } + return ecode.Tag(ecode.ErrStepFailed, "Mail 模块已接入,当前支持:/mail status, /mail help"), nil +} diff --git a/internal/qq/ai_loader.go b/internal/qq/ai_loader.go new file mode 100644 index 0000000..25d17cb --- /dev/null +++ b/internal/qq/ai_loader.go @@ -0,0 +1,11 @@ +package qq + +import ( + "ops-assistant/internal/core/ai" + + "gorm.io/gorm" +) + +func buildAIClient(db *gorm.DB) *ai.Client { + return ai.LoadClient(db) +} diff --git a/internal/qq/qq.go b/internal/qq/qq.go new file mode 100644 index 0000000..4357d67 --- /dev/null +++ b/internal/qq/qq.go @@ -0,0 +1,474 @@ +package qq + +import ( + "context" + "fmt" + "log" + "path" + "regexp" + "strconv" + "strings" + "time" + + "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" + "github.com/tencent-connect/botgo/dto/message" + "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" +) + +// DefaultUserID 统一用户ID,使所有平台共享同一份账本 +const DefaultUserID int64 = 1 + +type QQBot struct { + api openapi.OpenAPI + finance *service.FinanceService + credentials *token.QQBotCredentials + db *gorm.DB + opsSvc *ops.Service + aiClient *ai.Client + aiAutoReload time.Time +} + +func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, 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(), + } +} + +func (b *QQBot) Start(ctx context.Context) { + tokenSource := token.NewQQBotTokenSource(b.credentials) + if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil { + log.Printf("❌ QQ Bot Token 刷新失败: %v", err) + return + } + + b.api = botgo.NewOpenAPI(b.credentials.AppID, tokenSource).WithTimeout(5 * time.Second) + + _ = event.RegisterHandlers( + b.groupATMessageHandler(), + b.c2cMessageHandler(), + b.channelATMessageHandler(), + ) + + wsInfo, err := b.api.WS(ctx, nil, "") + if err != nil { + log.Printf("❌ QQ Bot 获取 WS 信息失败: %v", err) + return + } + + intent := dto.Intent(1<<25 | 1<<30) + + log.Printf("🚀 QQ Bot 已启动 (WebSocket, shards=%d)", wsInfo.Shards) + + if err := botgo.NewSessionManager().Start(wsInfo, tokenSource, &intent); err != nil { + log.Printf("❌ QQ Bot WebSocket 断开: %v", err) + } +} + +func isCommand(text string, keywords ...string) bool { + for _, kw := range keywords { + if text == kw { + return true + } + } + return false +} + +func (b *QQBot) isDuplicate(eventID string) bool { + if b.db == nil || strings.TrimSpace(eventID) == "" { + return false + } + var existed models.MessageDedup + if err := b.db.Where("platform = ? AND event_id = ?", "qqbot_official", eventID).First(&existed).Error; err == nil { + return true + } + _ = b.db.Create(&models.MessageDedup{Platform: "qqbot_official", EventID: eventID, ProcessedAt: time.Now()}).Error + return false +} + +func (b *QQBot) processAndReply(userID string, content string) string { + text := strings.TrimSpace(message.ETLInput(content)) + if text == "" { + return "" + } + + // 先交给 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 ") { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, text) + } + } + return out + } + } + + // 规则兜底:常见自然语映射到标准命令 + if b.opsSvc != nil { + norm := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(text), " ", ""), " ", "") + if (strings.Contains(norm, "域名") || strings.Contains(norm, "站点")) && !strings.Contains(norm, "解析") && !strings.Contains(strings.ToLower(norm), "dns") { + cmd := "/cf zones" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + } + switch norm { + case "cpa状态", "CPA状态", "cpaStatus", "cpastatus": + cmd := "/cpa status" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + case "功能", "菜单", "帮助", "help", "Help", "HELP", "你能做什么", "你会什么": + cmd := "/help" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + return out + } + case "cf状态", "cf配置", "cf配置状态", "cloudflare状态", "cloudflare配置": + cmd := "/cf status" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + case "cf域名", "cf账号域名", "cfzones", "cf zones", "cloudflare域名", "cloudflare站点", "站点列表", "域名列表", "我的域名", "域名清单": + cmd := "/cf zones" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + case "cf解析", "cf记录", "解析记录", "dns记录", "dns列表", "列解析", "列记录": + return "❌ 缺少 zone_id,请用:/cf dns list " + case "cfworkers", "cf workers", "workers列表", "workers list", "列workers": + cmd := "/cf workers list" + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + return out + } + } + } + + // 非命令:尝试 AI 翻译 -> 标准命令 + if time.Since(b.aiAutoReload) > 3*time.Second { + b.aiClient = buildAIClient(b.db) + b.aiAutoReload = time.Now() + } + if b.aiClient != nil { + if cmd, err := b.aiClient.Suggest(text); err == nil { + cmd = strings.TrimSpace(cmd) + if cmd != "" && cmd != "FAIL" && strings.HasPrefix(cmd, "/") { + // 仅翻译成命令,交给模块处理(不回译文) + if b.opsSvc != nil { + if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled { + if strings.HasPrefix(cmd, "/cpa ") || cmd == "/cpa" || strings.HasPrefix(cmd, "/cf ") || strings.HasPrefix(cmd, "/mail ") { + jobID := parseJobID(out) + if jobID > 0 { + go b.waitAndPushJobResult(userID, jobID, cmd) + } + } + return out + } + } + return "❌ 无法识别,请使用标准命令" + } + return "❌ 无法识别,请使用标准命令" + } else { + if strings.Contains(err.Error(), "rate limited") { + return "⚠️ AI 服务繁忙,请稍后再试或使用标准命令" + } + return "❌ 无法识别,请使用标准命令" + } + } + // 仍然无法处理 + return "❌ 无法识别,请使用标准命令" +} + +func parseJobID(out string) uint { + re := regexp.MustCompile(`job=(\d+)`) + m := re.FindStringSubmatch(out) + if len(m) < 2 { + return 0 + } + n, _ := strconv.Atoi(m[1]) + if n <= 0 { + return 0 + } + return uint(n) +} + +func (b *QQBot) waitAndPushJobResult(userID string, jobID uint, cmd string) { + if b.db == nil { + return + } + var job models.OpsJob + for i := 0; i < 15; i++ { // 最多等 ~30s + time.Sleep(2 * time.Second) + if err := b.db.First(&job, jobID).Error; err != nil { + continue + } + if job.Status == "pending" || job.Status == "running" { + continue + } + break + } + if job.ID == 0 || job.Status == "pending" || job.Status == "running" { + return + } + msg := formatJobResult(b.db, jobID, cmd) + if strings.TrimSpace(msg) == "" { + return + } + _, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg}) + if err != nil { + log.Printf("QQ 推送任务结果失败: %v", err) + } +} + +func formatJobResult(db *gorm.DB, jobID uint, cmd string) string { + var job models.OpsJob + if err := db.First(&job, jobID).Error; err != nil { + return "" + } + if job.Runbook == "cpa_status" { + return formatCPAStatusResult(db, jobID, job.Status) + } + if job.Runbook == "cpa_usage_backup" { + return formatCPAUsageBackupResult(db, jobID) + } + if job.Runbook == "cf_zones" { + return formatCFZonesResult(db, jobID, cmd) + } + if job.Runbook == "cf_workers_list" { + return formatCFWorkersResult(db, jobID, cmd) + } + return fmt.Sprintf("📦 %s 结果:%s (job=%d)", strings.TrimSpace(cmd), job.Status, jobID) +} + +func formatCFZonesResult(db *gorm.DB, jobID uint, cmd string) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var raw string + for _, st := range steps { + if st.StepID == "list_zones" { + raw = strings.TrimSpace(st.StdoutTail) + break + } + } + if raw == "" { + return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID) + } + arr := gjson.Get(raw, "zones").Array() + if len(arr) == 0 { + arr = gjson.Get(raw, "result").Array() + } + if len(arr) == 0 { + return fmt.Sprintf("📦 %s 结果:success (job=%d)\n(no zones)", strings.TrimSpace(cmd), jobID) + } + lines := make([]string, 0, len(arr)+2) + lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID)) + limit := len(arr) + if limit > 50 { + limit = 50 + } + for i := 0; i < limit; i++ { + name := arr[i].Get("name").String() + id := arr[i].Get("id").String() + if name == "" && id == "" { + continue + } + if id != "" { + lines = append(lines, fmt.Sprintf("- %s (%s)", name, id)) + } else { + lines = append(lines, fmt.Sprintf("- %s", name)) + } + } + if len(arr) > limit { + lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit)) + } + return strings.Join(lines, "\n") +} + +func formatCFWorkersResult(db *gorm.DB, jobID uint, cmd string) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var raw string + for _, st := range steps { + if st.StepID == "list_workers" { + raw = strings.TrimSpace(st.StdoutTail) + break + } + } + if raw == "" { + return fmt.Sprintf("📦 %s 结果:success (job=%d)", strings.TrimSpace(cmd), jobID) + } + arr := gjson.Get(raw, "workers").Array() + if len(arr) == 0 { + arr = gjson.Get(raw, "result").Array() + } + lines := make([]string, 0, len(arr)+2) + lines = append(lines, fmt.Sprintf("✅ %s 完成 (job=%d)", strings.TrimSpace(cmd), jobID)) + if len(arr) == 0 { + lines = append(lines, "(no workers)") + return strings.Join(lines, "\n") + } + limit := len(arr) + if limit > 50 { + limit = 50 + } + for i := 0; i < limit; i++ { + name := arr[i].Get("id").String() + if name == "" { + name = arr[i].Get("name").String() + } + if name == "" { + continue + } + lines = append(lines, fmt.Sprintf("- %s", name)) + } + if len(arr) > limit { + lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(arr), limit)) + } + return strings.Join(lines, "\n") +} + +func formatCPAUsageBackupResult(db *gorm.DB, jobID uint) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var raw string + for _, st := range steps { + if st.StepID == "export_and_package" { + raw = strings.TrimSpace(st.StdoutTail) + break + } + } + if raw == "" { + return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID) + } + backup := "" + for _, line := range strings.Split(raw, "\n") { + if strings.HasPrefix(line, "backup=") { + backup = strings.TrimSpace(strings.TrimPrefix(line, "backup=")) + break + } + } + if backup == "" { + return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)", jobID) + } + file := path.Base(backup) + return fmt.Sprintf("✅ /cpa usage backup 执行成功(job=%d)\n📦 备份文件:%s\n📁 路径:%s", jobID, file, path.Dir(backup)+"/") +} + +func formatCPAStatusResult(db *gorm.DB, jobID uint, status string) string { + var steps []models.OpsJobStep + _ = db.Where("job_id = ?", jobID).Order("id asc").Find(&steps).Error + var svc, usage string + for _, st := range steps { + if st.StepID == "service_status" { + svc = strings.TrimSpace(st.StdoutTail) + } + if st.StepID == "usage_snapshot" { + usage = strings.TrimSpace(st.StdoutTail) + } + } + tr := gjson.Get(usage, "usage.total_requests").String() + tt := gjson.Get(usage, "usage.total_tokens").String() + if tr == "" { + tr = "-" + } + if tt == "" { + tt = "-" + } + return fmt.Sprintf("✅ /cpa status 完成 (job=%d)\nservice=%s\nrequests=%s\ntokens=%s", jobID, svc, tr, tt) +} + +func (b *QQBot) channelATMessageHandler() event.ATMessageEventHandler { + return func(ev *dto.WSPayload, data *dto.WSATMessageData) error { + eventID := "qq:channel:" + strings.TrimSpace(data.ID) + if b.isDuplicate(eventID) { + return nil + } + log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.ChannelID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) + reply := b.processAndReply(data.Author.ID, data.Content) + if reply == "" { + return nil + } + _, err := b.api.PostMessage(context.Background(), data.ChannelID, &dto.MessageToCreate{MsgID: data.ID, Content: reply}) + if err != nil { + log.Printf("QQ频道消息发送失败: %v", err) + } + return nil + } +} + +func (b *QQBot) groupATMessageHandler() event.GroupATMessageEventHandler { + return func(ev *dto.WSPayload, data *dto.WSGroupATMessageData) error { + eventID := "qq:group:" + strings.TrimSpace(data.ID) + if b.isDuplicate(eventID) { + return nil + } + log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.GroupID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) + reply := b.processAndReply(data.Author.ID, data.Content) + if reply == "" { + return nil + } + _, err := b.api.PostGroupMessage(context.Background(), data.GroupID, dto.MessageToCreate{MsgID: data.ID, Content: reply}) + if err != nil { + log.Printf("QQ群消息发送失败: %v", err) + } + return nil + } +} + +func (b *QQBot) c2cMessageHandler() event.C2CMessageEventHandler { + return func(ev *dto.WSPayload, data *dto.WSC2CMessageData) error { + eventID := "qq:c2c:" + strings.TrimSpace(data.ID) + if b.isDuplicate(eventID) { + return nil + } + log.Printf("📩 inbound platform=qqbot_official event=%s chat=%s user=%s text=%q", eventID, data.Author.ID, data.Author.ID, strings.TrimSpace(message.ETLInput(data.Content))) + reply := b.processAndReply(data.Author.ID, data.Content) + if reply == "" { + return nil + } + _, err := b.api.PostC2CMessage(context.Background(), data.Author.ID, dto.MessageToCreate{MsgID: data.ID, Content: reply}) + if err != nil { + log.Printf("QQ私聊消息发送失败: %v", err) + } + return nil + } +} diff --git a/internal/service/finance.go b/internal/service/finance.go new file mode 100644 index 0000000..17407ac --- /dev/null +++ b/internal/service/finance.go @@ -0,0 +1,154 @@ +package service + +import ( + "math" + "regexp" + "strconv" + "time" + + "ops-assistant/models" + + "github.com/yanyiwu/gojieba" + "gorm.io/gorm" +) + +type FinanceService struct { + db *gorm.DB + jieba *gojieba.Jieba +} + +func NewFinanceService(db *gorm.DB) *FinanceService { + return &FinanceService{ + db: db, + jieba: gojieba.NewJieba(), + } +} + +func (s *FinanceService) Close() { + s.jieba.Free() +} + +// ParseText 从自然语言文本中提取金额(分)和分类 +func (s *FinanceService) ParseText(text string) (int64, string) { + // 1. 提取金额 — 优先匹配带单位的,如 "15.5元"、"¥30"、"20块" + amountPatterns := []*regexp.Regexp{ + regexp.MustCompile(`[¥¥]\s*(\d+\.?\d*)`), + regexp.MustCompile(`(\d+\.?\d*)\s*[元块]`), + } + + var amountStr string + for _, re := range amountPatterns { + m := re.FindStringSubmatch(text) + if len(m) > 1 { + amountStr = m[1] + break + } + } + + // 兜底:取最后一个独立数字 + if amountStr == "" { + re := regexp.MustCompile(`(\d+\.?\d*)`) + matches := re.FindAllStringSubmatch(text, -1) + if len(matches) > 0 { + amountStr = matches[len(matches)-1][1] + } + } + + if amountStr == "" { + return 0, "" + } + + amountFloat, err := strconv.ParseFloat(amountStr, 64) + if err != nil || amountFloat <= 0 { + return 0, "" + } + + // 转为分 + amountCents := int64(math.Round(amountFloat * 100)) + + // 2. 提取分类(Jieba 分词 + 数据库匹配) + words := s.jieba.Cut(text, true) + category := "其他" + + for _, word := range words { + var ck models.CategoryKeyword + if err := s.db.Where("keyword = ?", word).First(&ck).Error; err == nil { + category = ck.Category + break + } + } + + return amountCents, category +} + +// AddTransaction 解析文本并创建一条交易记录 +func (s *FinanceService) AddTransaction(userID int64, text string) (int64, string, error) { + amount, category := s.ParseText(text) + if amount == 0 { + return 0, "", nil + } + + tx := models.Transaction{ + UserID: userID, + Amount: amount, + Category: category, + Note: text, + Date: time.Now().Format("2006-01-02"), + } + + return amount, category, s.db.Create(&tx).Error +} + +// GetTransactions 获取用户的交易记录 +func (s *FinanceService) GetTransactions(userID int64, limit int) ([]models.Transaction, error) { + var items []models.Transaction + err := s.db.Where("user_id = ? AND is_deleted = ?", userID, false). + Order("id desc").Limit(limit).Find(&items).Error + return items, err +} + +// GetTransactionsByDate 获取用户指定日期的交易记录 +func (s *FinanceService) GetTransactionsByDate(userID int64, date string) ([]models.Transaction, error) { + var items []models.Transaction + err := s.db.Where("user_id = ? AND date = ? AND is_deleted = ?", userID, date, false). + Order("id desc").Find(&items).Error + return items, err +} + +// CategoryStat 分类统计结果 +type CategoryStat struct { + Category string + Total int64 + Count int +} + +// GetCategoryStats 获取用户指定日期范围的分类统计 +func (s *FinanceService) GetCategoryStats(userID int64, dateFrom, dateTo string) ([]CategoryStat, error) { + var stats []CategoryStat + err := s.db.Model(&models.Transaction{}). + Select("category, SUM(amount) as total, COUNT(*) as count"). + Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false). + Group("category"). + Order("total desc"). + Find(&stats).Error + return stats, err +} + +// DailyStat 每日统计结果 +type DailyStat struct { + Date string + Total int64 + Count int +} + +// GetDailyStats 获取用户指定日期范围的每日统计 +func (s *FinanceService) GetDailyStats(userID int64, dateFrom, dateTo string) ([]DailyStat, error) { + var stats []DailyStat + err := s.db.Model(&models.Transaction{}). + Select("date, SUM(amount) as total, COUNT(*) as count"). + Where("user_id = ? AND date >= ? AND date <= ? AND is_deleted = ?", userID, dateFrom, dateTo, false). + Group("date"). + Order("date asc"). + Find(&stats).Error + return stats, err +} diff --git a/internal/web/apiresp.go b/internal/web/apiresp.go new file mode 100644 index 0000000..35143e1 --- /dev/null +++ b/internal/web/apiresp.go @@ -0,0 +1,17 @@ +package web + +import "github.com/gin-gonic/gin" + +type apiResp struct { + Code string `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func respondOK(c *gin.Context, message string, data any) { + c.JSON(200, apiResp{Code: "OK", Message: message, Data: data}) +} + +func respondErr(c *gin.Context, status int, code, message string) { + c.JSON(status, apiResp{Code: code, Message: message}) +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..96bf63a --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,2074 @@ +package web + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "ops-assistant/internal/channel" + "ops-assistant/internal/core/ecode" + "ops-assistant/internal/core/ops" + "ops-assistant/internal/core/runbook" + "ops-assistant/internal/service" + "ops-assistant/models" + "ops-assistant/version" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const ( + cookieUserNew = "ops_user" + cookieTokenNew = "ops_token" + cookieUserOld = "xiaji_user" + cookieTokenOld = "xiaji_token" +) + +type WebServer struct { + db *gorm.DB + dbPath string + baseDir string + finance *service.FinanceService + port int + username string + password string + secretKey string + reloadFn func() (string, error) +} + +type CurrentUser struct { + Username string `json:"username"` + Role string `json:"role"` + UserID int64 `json:"user_id"` + Permissions map[string]bool `json:"-"` + PermList []string `json:"permissions"` + Flags map[string]bool `json:"flags"` + Caps map[string]bool `json:"effective_capabilities"` +} + +type flagPatchReq struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason"` +} + +type moduleToggleReq struct { + Enabled bool `json:"enabled"` + Reason string `json:"reason"` +} + +type opsJobActionReq struct { + Reason string `json:"reason"` +} + +type channelConfigPatchReq struct { + Name *string `json:"name"` + Enabled *bool `json:"enabled"` + Config json.RawMessage `json:"config"` + Secrets json.RawMessage `json:"secrets"` +} + +type cpaSettingsReq struct { + ManagementToken string `json:"management_token"` + ManagementBase string `json:"management_base"` +} + +type cfSettingsReq struct { + AccountID string `json:"account_id"` + APIEmail string `json:"api_email"` + APIToken string `json:"api_token"` +} + +type aiSettingsReq struct { + Enabled *bool `json:"enabled"` + BaseURL string `json:"base_url"` + APIKey string `json:"api_key"` + Model string `json:"model"` + TimeoutSeconds int `json:"timeout_seconds"` +} + +type opsTargetReq struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` + User string `json:"user"` + Enabled bool `json:"enabled"` +} + +var ( + validHostRe = regexp.MustCompile(`^(?:[a-zA-Z0-9-]+\.)*[a-zA-Z0-9-]+$`) + validUserRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) +) + +func validateTargetFields(host, user string, port int) error { + host = strings.TrimSpace(host) + user = strings.TrimSpace(user) + if host == "" || user == "" { + return fmt.Errorf("host/user 不能为空") + } + if ip := net.ParseIP(host); ip == nil { + if !validHostRe.MatchString(host) { + return fmt.Errorf("host 非法") + } + } + if !validUserRe.MatchString(user) { + return fmt.Errorf("user 非法") + } + if port <= 0 || port > 65535 { + return fmt.Errorf("port 无效") + } + return nil +} + +var rolePermissions = map[string][]string{ + "owner": { + "records.read.self", "records.read.all", + "records.delete.self", "records.delete.all", + "records.export.self", "records.export.all", + "settings.flags.read", "settings.flags.write", + "channels.read", "channels.write", "channels.test", + "audit.read", "ops.read", "ops.cancel", "ops.retry", + }, + "admin": { + "records.read.self", "records.delete.self", "records.export.self", + "settings.flags.read", "channels.read", "audit.read", "ops.read", "ops.cancel", "ops.retry", + }, + "viewer": { + "records.read.self", + }, +} + +func NewWebServer(db *gorm.DB, dbPath, baseDir string, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer { + return &WebServer{ + db: db, + dbPath: dbPath, + baseDir: baseDir, + finance: finance, + port: port, + username: username, + password: password, + secretKey: "ops-assistant-session-" + sessionKey, + reloadFn: reloadFn, + } +} + +func (s *WebServer) generateToken(username string) string { + exp := time.Now().Add(7 * 24 * time.Hour).Unix() + payload := fmt.Sprintf("%s|%d", username, exp) + mac := hmac.New(sha256.New, []byte(s.secretKey)) + mac.Write([]byte(payload)) + sig := hex.EncodeToString(mac.Sum(nil)) + return fmt.Sprintf("%s|%s", payload, sig) +} + +func (s *WebServer) validateToken(username, token string) bool { + parts := strings.Split(token, "|") + if len(parts) == 1 { + // legacy token: HMAC(username) + expected := s.generateLegacyToken(username) + return hmac.Equal([]byte(expected), []byte(token)) + } + if len(parts) != 3 { + return false + } + if parts[0] != username { + return false + } + exp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return false + } + if time.Now().Unix() > exp { + return false + } + payload := fmt.Sprintf("%s|%s", parts[0], parts[1]) + mac := hmac.New(sha256.New, []byte(s.secretKey)) + mac.Write([]byte(payload)) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(parts[2])) +} + +func (s *WebServer) generateLegacyToken(username string) string { + mac := hmac.New(sha256.New, []byte(s.secretKey)) + mac.Write([]byte(username)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *WebServer) buildCurrentUser(username string) *CurrentUser { + role := "viewer" + userID := int64(1) + if username == s.username { + role = "owner" + } + perms := map[string]bool{} + permList := make([]string, 0) + for _, p := range rolePermissions[role] { + perms[p] = true + permList = append(permList, p) + } + return &CurrentUser{Username: username, Role: role, UserID: userID, Permissions: perms, PermList: permList} +} + +func (s *WebServer) getFlagMap() map[string]bool { + res := map[string]bool{} + var flags []models.FeatureFlag + s.db.Find(&flags) + for _, f := range flags { + res[f.Key] = f.Enabled + } + return res +} + +func (s *WebServer) flagEnabled(key string) bool { + var ff models.FeatureFlag + if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { + return false + } + return ff.Enabled +} + +func (s *WebServer) hasPermission(u *CurrentUser, perm string) bool { + if u == nil { + return false + } + return u.Permissions[perm] +} + +func (s *WebServer) requirePerm(c *gin.Context, u *CurrentUser, perm, msg string) bool { + if s.hasPermission(u, perm) { + return true + } + deny(c, msg) + return false +} + +func (s *WebServer) renderPage(c *gin.Context, tpl string, u *CurrentUser, extra gin.H) { + data := gin.H{"version": "v" + version.Version} + if u != nil { + data["username"] = u.Username + } + for k, v := range extra { + data[k] = v + } + c.HTML(http.StatusOK, tpl, data) +} + +func deny(c *gin.Context, msg string) { + respondErr(c, http.StatusForbidden, ecode.ErrPermissionDenied, msg) +} + +func currentUser(c *gin.Context) *CurrentUser { + if v, ok := c.Get("currentUser"); ok { + if u, ok2 := v.(*CurrentUser); ok2 { + return u + } + } + return nil +} + +func (s *WebServer) authRequired() gin.HandlerFunc { + return func(c *gin.Context) { + username, _ := c.Cookie(cookieUserNew) + token, _ := c.Cookie(cookieTokenNew) + + legacy := false + if username == "" || token == "" { + username, _ = c.Cookie(cookieUserOld) + token, _ = c.Cookie(cookieTokenOld) + legacy = username != "" && token != "" + } + + if username == "" || token == "" || !s.validateToken(username, token) { + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api") || c.Request.Method == "POST" || c.Request.Method == "PATCH" { + respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录") + } else { + c.Redirect(http.StatusFound, "/login") + } + c.Abort() + return + } + + if legacy { + maxAge := 7 * 24 * 3600 + c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) + c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true) + } else if strings.Contains(token, "|") { + // refresh exp for new token format + maxAge := 7 * 24 * 3600 + fresh := s.generateToken(username) + c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) + c.SetCookie(cookieTokenNew, fresh, maxAge, "/", "", false, true) + } + + c.Set("currentUser", s.buildCurrentUser(username)) + c.Next() + } +} + +func (s *WebServer) writeAudit(actor int64, action, targetType, targetID, before, after, note string) { + _ = s.db.Create(&models.AuditLog{ + ActorID: actor, + Action: action, + TargetType: targetType, + TargetID: targetID, + BeforeJSON: before, + AfterJSON: after, + Note: note, + }).Error +} + +func (s *WebServer) writeAuditResult(actor int64, action, targetType, targetID, before, after, note, result string) { + finalNote := strings.TrimSpace(note) + if strings.TrimSpace(result) != "" { + if finalNote == "" { + finalNote = "result=" + result + } else { + finalNote = finalNote + " | result=" + result + } + } + s.writeAudit(actor, action, targetType, targetID, before, after, finalNote) +} + +func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) { + auth.GET("/api/v1/me", s.handleMe) + auth.GET("/api/v1/records", s.handleRecordsV1) + auth.POST("/api/v1/records/:id/delete", s.handleDeleteV1) + auth.GET("/api/v1/export", s.handleExportV1) + auth.GET("/api/v1/admin/settings/flags", s.handleFlagsList) + auth.PATCH("/api/v1/admin/settings/flags/:key", s.handleFlagPatch) + // CPA settings + auth.GET("/api/v1/admin/cpa/settings", s.handleCPASettingsGet) + auth.PATCH("/api/v1/admin/cpa/settings", s.handleCPASettingsPatch) + // Cloudflare settings + auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet) + auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch) + // AI settings + auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet) + auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch) + // Ops targets + auth.GET("/api/v1/admin/ops/targets", s.handleOpsTargetsList) + auth.POST("/api/v1/admin/ops/targets", s.handleOpsTargetsCreate) + auth.PATCH("/api/v1/admin/ops/targets/:id", s.handleOpsTargetsPatch) + + auth.GET("/api/v1/admin/channels", s.handleChannelsList) + auth.PATCH("/api/v1/admin/channels/:platform", s.handleChannelPatch) + auth.POST("/api/v1/admin/channels/:platform/publish", s.handleChannelPublish) + auth.POST("/api/v1/admin/channels/reload", s.handleChannelReload) + auth.POST("/api/v1/admin/channels/disable-all", s.handleChannelDisableAll) + auth.POST("/api/v1/admin/channels/:platform/enable", s.handleChannelEnable) + auth.POST("/api/v1/admin/channels/:platform/disable", s.handleChannelDisable) + auth.POST("/api/v1/admin/channels/:platform/test", s.handleChannelTest) + auth.POST("/api/v1/admin/channels/:platform/apply", s.handleChannelApply) + auth.GET("/api/v1/admin/audit", s.handleAuditList) + auth.GET("/api/v1/admin/legacy/usage", s.handleLegacyUsage) + auth.GET("/api/v1/admin/legacy/trend", s.handleLegacyTrend) + auth.GET("/api/v1/admin/legacy/readiness", s.handleLegacyReadiness) + auth.GET("/api/v1/modules", s.handleModulesList) + auth.POST("/api/v1/modules/:module/toggle", s.handleModuleToggle) + auth.GET("/api/v1/dashboard/overview", s.handleDashboardOverview) + auth.GET("/api/v1/dashboard/summary", s.handleDashboardSummary) + auth.GET("/api/v1/ops/jobs", s.handleOpsJobs) + auth.GET("/api/v1/ops/jobs/request/:requestID", s.handleOpsJobsByRequestID) + auth.GET("/api/v1/ops/jobs/:id", s.handleOpsJobDetail) + auth.POST("/api/v1/ops/jobs/:id/cancel", s.handleOpsJobCancel) + auth.POST("/api/v1/ops/jobs/:id/retry", s.handleOpsJobRetry) +} + +func (s *WebServer) registerLegacyCompatRoutes(auth *gin.RouterGroup) { + // 兼容老前端调用,统一复用 v1 handler(兼容层) + // + // 废弃计划(仅文档约束,当前不删): + // 1) 新功能与新页面只允许使用 /api/v1/* + // 2) 当确认无旧调用后,再移除以下旧路由映射 + // 3) 每次版本发布前,优先检查是否仍存在对旧路由的引用 + auth.GET("/api/records", s.handleLegacyRecords) + auth.POST("/delete/:id", s.handleLegacyDelete) + auth.GET("/export", s.handleLegacyExport) +} + +func (s *WebServer) writeLegacyAccess(c *gin.Context, route string) { + u := currentUser(c) + uid := int64(0) + if u != nil { + uid = u.UserID + } + note := fmt.Sprintf("legacy route=%s method=%s path=%s ua=%s", route, c.Request.Method, c.Request.URL.Path, c.Request.UserAgent()) + s.writeAuditResult(uid, "legacy.route.access", "route", route, "", "", note, "success") +} + +func (s *WebServer) markLegacyDeprecated(c *gin.Context, replacement string) { + c.Header("X-API-Deprecated", "true") + c.Header("X-API-Replacement", replacement) + c.Header("Warning", fmt.Sprintf(`299 - "legacy API deprecated, use %s"`, replacement)) +} + +func (s *WebServer) handleLegacyRecords(c *gin.Context) { + s.writeLegacyAccess(c, "/api/records") + s.markLegacyDeprecated(c, "/api/v1/records") + s.handleRecordsV1(c) +} + +func (s *WebServer) handleLegacyDelete(c *gin.Context) { + s.writeLegacyAccess(c, "/delete/:id") + s.markLegacyDeprecated(c, "/api/v1/records/:id/delete") + s.handleDeleteV1(c) +} + +func (s *WebServer) handleLegacyExport(c *gin.Context) { + s.writeLegacyAccess(c, "/export") + s.markLegacyDeprecated(c, "/api/v1/export") + s.handleExportV1(c) +} + +func (s *WebServer) RegisterRoutes(r *gin.Engine) { + r.LoadHTMLGlob("templates/*") + + r.GET("/login", s.handleLoginPage) + r.POST("/login", s.handleLogin) + r.GET("/logout", s.handleLogout) + r.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + + auth := r.Group("/") + auth.Use(s.authRequired()) + { + auth.GET("/", s.handleIndex) + auth.GET("/channels", s.handleChannelsPage) + auth.GET("/audit", s.handleAuditPage) + auth.GET("/ops", s.handleOpsPage) + auth.GET("/cpa", s.handleCPASettingsPage) + auth.GET("/cf", s.handleCFSettingsPage) + // AI 配置页临时隐藏(保留后端接口) + + s.registerAPIV1Routes(auth) + s.registerLegacyCompatRoutes(auth) + } +} + +func (s *WebServer) Start() { + gin.SetMode(gin.ReleaseMode) + r := gin.New() + r.Use(gin.Recovery()) + r.Use(gin.Logger()) + s.RegisterRoutes(r) + + logAddr := fmt.Sprintf(":%d", s.port) + fmt.Printf("🌐 Web后台运行在 http://127.0.0.1%s\n", logAddr) + if err := r.Run(logAddr); err != nil { + fmt.Printf("❌ Web服务启动失败: %v\n", err) + } +} + +func (s *WebServer) handleLoginPage(c *gin.Context) { + username, _ := c.Cookie(cookieUserNew) + token, _ := c.Cookie(cookieTokenNew) + if username == "" || token == "" { + username, _ = c.Cookie(cookieUserOld) + token, _ = c.Cookie(cookieTokenOld) + } + if username != "" && token != "" && s.validateToken(username, token) { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "login.html", nil, gin.H{"error": ""}) +} + +func (s *WebServer) handleLogin(c *gin.Context) { + username := c.PostForm("username") + password := c.PostForm("password") + + if username == s.username && password == s.password { + token := s.generateToken(username) + maxAge := 7 * 24 * 3600 + c.SetCookie(cookieUserNew, username, maxAge, "/", "", false, true) + c.SetCookie(cookieTokenNew, token, maxAge, "/", "", false, true) + c.SetCookie(cookieUserOld, "", -1, "/", "", false, true) + c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true) + u := s.buildCurrentUser(username) + s.writeAuditResult(u.UserID, "auth.login.success", "user", username, "", "", "", "success") + c.Redirect(http.StatusFound, "/") + return + } + + s.writeAuditResult(0, "auth.login.failed", "user", username, "", "", "用户名或密码错误", "failed") + s.renderPage(c, "login.html", nil, gin.H{"error": "用户名或密码错误"}) +} + +func (s *WebServer) handleLogout(c *gin.Context) { + u := currentUser(c) + if u != nil { + s.writeAuditResult(u.UserID, "auth.logout", "user", u.Username, "", "", "", "success") + } + c.SetCookie(cookieUserNew, "", -1, "/", "", false, true) + c.SetCookie(cookieTokenNew, "", -1, "/", "", false, true) + c.SetCookie(cookieUserOld, "", -1, "/", "", false, true) + c.SetCookie(cookieTokenOld, "", -1, "/", "", false, true) + c.Redirect(http.StatusFound, "/login") +} + +func (s *WebServer) handleIndex(c *gin.Context) { + u := currentUser(c) + s.renderPage(c, "index.html", u, nil) +} + +func (s *WebServer) handleChannelsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "channels.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "channels.html", u, nil) +} + +func (s *WebServer) handleAuditPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "audit.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "audit.html", u, nil) +} + +func (s *WebServer) handleOpsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "ops.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "ops.html", u, nil) +} + +func (s *WebServer) handleCPASettingsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "cpa_settings.html", u, nil) +} + +func (s *WebServer) handleCFSettingsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "cf_settings.html", u, nil) +} + +func (s *WebServer) handleAISettingsPage(c *gin.Context) { + u := currentUser(c) + if u == nil || !s.hasPermission(u, "settings.flags.read") { + c.Redirect(http.StatusFound, "/") + return + } + s.renderPage(c, "ai_settings.html", u, nil) +} + +func (s *WebServer) handleCPASettingsGet(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + keys := []string{"cpa_management_token", "cpa_management_base"} + 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] = "" + } + } + // do not expose secret token + if v := strings.TrimSpace(out["cpa_management_token"]); v != "" { + out["cpa_management_token"] = "***" + } + respondOK(c, "ok", gin.H{"settings": out}) +} + +func (s *WebServer) handleCPASettingsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req cpaSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + // management_token + key := "cpa_management_token" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.ManagementToken, UpdatedBy: u.UserID} + if err := s.db.Create(&sset).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + } else { + old := sset.Value + sset.Value = req.ManagementToken + sset.UpdatedBy = u.UserID + if err := s.db.Save(&sset).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementToken != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error + } + + // management_base + key = "cpa_management_base" + var ssetBase models.AppSetting + if err := s.db.Where("key = ?", key).First(&ssetBase).Error; err != nil { + ssetBase = models.AppSetting{Key: key, Value: req.ManagementBase, UpdatedBy: u.UserID} + if err := s.db.Create(&ssetBase).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + } else { + old := ssetBase.Value + ssetBase.Value = req.ManagementBase + ssetBase.UpdatedBy = u.UserID + if err := s.db.Save(&ssetBase).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error + } + + s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success") + respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}}) +} + +func (s *WebServer) handleCFSettingsGet(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + keys := []string{"cf_account_id", "cf_api_email", "cf_api_token"} + 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) handleCFSettingsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req cfSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + // account id + if req.AccountID != "" { + key := "cf_account_id" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.AccountID, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.AccountID + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + // api email + if req.APIEmail != "" { + key := "cf_api_email" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.APIEmail, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.APIEmail + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + // api token + if req.APIToken != "" { + key := "cf_api_token" + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: req.APIToken, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + } else { + sset.Value = req.APIToken + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + } + respondOK(c, "success", gin.H{"ok": true}) +} + +func (s *WebServer) handleAISettingsGet(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + keys := []string{"ai_enabled", "ai_base_url", "ai_api_key", "ai_model", "ai_timeout_seconds"} + 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) handleAISettingsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req aiSettingsReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + setKV := func(key, val string) { + var sset models.AppSetting + if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil { + sset = models.AppSetting{Key: key, Value: val, UpdatedBy: u.UserID} + _ = s.db.Create(&sset).Error + return + } + sset.Value = val + sset.UpdatedBy = u.UserID + _ = s.db.Save(&sset).Error + } + if req.Enabled != nil { + if *req.Enabled { + setKV("ai_enabled", "true") + } else { + setKV("ai_enabled", "false") + } + } + if strings.TrimSpace(req.BaseURL) != "" { + setKV("ai_base_url", strings.TrimSpace(req.BaseURL)) + } + if strings.TrimSpace(req.APIKey) != "" { + setKV("ai_api_key", strings.TrimSpace(req.APIKey)) + } + if strings.TrimSpace(req.Model) != "" { + setKV("ai_model", strings.TrimSpace(req.Model)) + } + if req.TimeoutSeconds > 0 { + setKV("ai_timeout_seconds", strconv.Itoa(req.TimeoutSeconds)) + } + s.writeAuditResult(u.UserID, "ai.settings.update", "settings", "ai", "", "", "", "success") + respondOK(c, "success", gin.H{"ok": true}) +} + +func (s *WebServer) handleOpsTargetsList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + var items []models.OpsTarget + s.db.Order("name asc").Find(&items) + respondOK(c, "ok", gin.H{"targets": items}) +} + +func (s *WebServer) handleOpsTargetsCreate(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + var req opsTargetReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Host) == "" || strings.TrimSpace(req.User) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "name/host/user 不能为空") + return + } + if req.Port == 0 { + req.Port = 22 + } + if err := validateTargetFields(req.Host, req.User, req.Port); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + item := models.OpsTarget{Name: strings.TrimSpace(req.Name), Host: strings.TrimSpace(req.Host), Port: req.Port, User: strings.TrimSpace(req.User), Enabled: req.Enabled} + if err := s.db.Create(&item).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "创建失败") + return + } + s.writeAuditResult(u.UserID, "ops_target.create", "ops_target", item.Name, "", "", "", "success") + respondOK(c, "success", gin.H{"target": item}) +} + +func (s *WebServer) handleOpsTargetsPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + id, _ := strconv.Atoi(strings.TrimSpace(c.Param("id"))) + if id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id 无效") + return + } + var req opsTargetReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + var item models.OpsTarget + if err := s.db.First(&item, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "目标不存在") + return + } + if strings.TrimSpace(req.Name) != "" { + item.Name = strings.TrimSpace(req.Name) + } + if strings.TrimSpace(req.Host) != "" { + item.Host = strings.TrimSpace(req.Host) + } + if strings.TrimSpace(req.User) != "" { + item.User = strings.TrimSpace(req.User) + } + if req.Port != 0 { + item.Port = req.Port + } + item.Enabled = req.Enabled + if err := validateTargetFields(item.Host, item.User, item.Port); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + if err := s.db.Save(&item).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") + return + } + s.writeAuditResult(u.UserID, "ops_target.update", "ops_target", item.Name, "", "", "", "success") + respondOK(c, "success", gin.H{"target": item}) +} + +func (s *WebServer) handleMe(c *gin.Context) { + u := currentUser(c) + if u == nil { + respondErr(c, http.StatusUnauthorized, ecode.ErrPermissionDenied, "未登录") + return + } + + flags := s.getFlagMap() + caps := map[string]bool{ + "can_read_self": s.hasPermission(u, "records.read.self"), + "can_read_all": s.hasPermission(u, "records.read.all") && flags["allow_cross_user_read"], + "can_delete_self": s.hasPermission(u, "records.delete.self"), + "can_delete_all": s.hasPermission(u, "records.delete.all") && flags["allow_cross_user_delete"], + "can_export_self": s.hasPermission(u, "records.export.self"), + "can_export_all": s.hasPermission(u, "records.export.all") && flags["allow_export_all_users"], + "can_view_flags": s.hasPermission(u, "settings.flags.read"), + "can_edit_flags": s.hasPermission(u, "settings.flags.write"), + "can_view_channels": s.hasPermission(u, "channels.read"), + "can_edit_channels": s.hasPermission(u, "channels.write"), + "can_test_channels": s.hasPermission(u, "channels.test"), + "can_view_audit": s.hasPermission(u, "audit.read"), + "can_view_ops": s.hasPermission(u, "ops.read"), + "can_cancel_ops": s.hasPermission(u, "ops.cancel"), + "can_retry_ops": s.hasPermission(u, "ops.retry"), + } + + u.Flags = flags + u.Caps = caps + respondOK(c, "ok", u) +} + +func (s *WebServer) handleRecordsV1(c *gin.Context) { + u := currentUser(c) + if !s.hasPermission(u, "records.read.self") { + s.writeAuditResult(u.UserID, "record.list.self", "transaction", "*", "", "", "无 records.read.self 权限", "denied") + deny(c, "无 records.read.self 权限") + return + } + + scope := c.DefaultQuery("scope", "self") + q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false) + action := "record.list.self" + note := "" + + if scope == "all" { + action = "record.list.all" + if !s.hasPermission(u, "records.read.all") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.read.all 权限", "denied") + deny(c, "无 records.read.all 权限") + return + } + if !s.flagEnabled("allow_cross_user_read") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_cross_user_read 未开启", "denied") + deny(c, "策略开关 allow_cross_user_read 未开启") + return + } + } else { + q = q.Where("user_id = ?", u.UserID) + } + + var items []models.Transaction + q.Order("id desc").Limit(100).Find(&items) + + type txResponse struct { + ID uint `json:"id"` + UserID int64 `json:"user_id"` + Amount float64 `json:"amount"` + Category string `json:"category"` + Note string `json:"note"` + Date string `json:"date"` + } + + resp := make([]txResponse, len(items)) + for i, item := range items { + resp[i] = txResponse{ID: item.ID, UserID: item.UserID, Amount: item.AmountYuan(), Category: item.Category, Note: item.Note, Date: item.Date} + } + note = fmt.Sprintf("scope=%s,count=%d", scope, len(resp)) + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", note, "success") + respondOK(c, "ok", gin.H{"records": resp}) +} + +func (s *WebServer) handleDeleteV1(c *gin.Context) { + u := currentUser(c) + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "无效的ID") + return + } + + var tx models.Transaction + if err := s.db.Where("id = ? AND is_deleted = ?", id, false).First(&tx).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "记录不存在或已删除") + return + } + + action := "record.delete.self" + if tx.UserID == u.UserID { + if !s.hasPermission(u, "records.delete.self") { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.self 权限", "denied") + deny(c, "无 records.delete.self 权限") + return + } + } else { + action = "record.delete.all" + if !s.hasPermission(u, "records.delete.all") { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "无 records.delete.all 权限", "denied") + deny(c, "无 records.delete.all 权限") + return + } + if !s.flagEnabled("allow_cross_user_delete") { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", "策略开关 allow_cross_user_delete 未开启", "denied") + deny(c, "策略开关 allow_cross_user_delete 未开启") + return + } + } + + result := s.db.Model(&models.Transaction{}).Where("id = ? AND is_deleted = ?", id, false).Update("is_deleted", true) + if result.Error != nil { + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", "", result.Error.Error(), "failed") + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "删除失败") + return + } + s.writeAuditResult(u.UserID, action, "transaction", fmt.Sprintf("%d", id), "", `{"is_deleted":true}`, "", "success") + respondOK(c, "success", gin.H{"id": id}) +} + +func (s *WebServer) handleExportV1(c *gin.Context) { + u := currentUser(c) + scope := c.DefaultQuery("scope", "self") + action := "record.export.self" + + q := s.db.Model(&models.Transaction{}).Where("is_deleted = ?", false) + if scope == "all" { + action = "record.export.all" + if !s.hasPermission(u, "records.export.all") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.all 权限", "denied") + deny(c, "无 records.export.all 权限") + return + } + if !s.flagEnabled("allow_export_all_users") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "策略开关 allow_export_all_users 未开启", "denied") + deny(c, "策略开关 allow_export_all_users 未开启") + return + } + } else { + if !s.hasPermission(u, "records.export.self") { + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", "无 records.export.self 权限", "denied") + deny(c, "无 records.export.self 权限") + return + } + q = q.Where("user_id = ?", u.UserID) + } + + var items []models.Transaction + q.Order("date asc, id asc").Find(&items) + + now := time.Now().Format("20060102") + filename := fmt.Sprintf("ops_assistant_%s.csv", now) + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + + c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) + c.Writer.WriteString("ID,用户ID,日期,分类,金额(元),备注\n") + for _, item := range items { + line := fmt.Sprintf("%d,%d,%s,%s,%.2f,\"%s\"\n", item.ID, item.UserID, item.Date, item.Category, item.AmountYuan(), item.Note) + c.Writer.WriteString(line) + } + s.writeAuditResult(u.UserID, action, "transaction", "*", "", "", fmt.Sprintf("scope=%s,count=%d", scope, len(items)), "success") +} + +func (s *WebServer) handleFlagsList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + var flags []models.FeatureFlag + s.db.Order("key asc").Find(&flags) + respondOK(c, "ok", gin.H{"flags": flags}) +} + +func (s *WebServer) handleFlagPatch(c *gin.Context) { + u := currentUser(c) + if !s.hasPermission(u, "settings.flags.write") { + s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", c.Param("key"), "", "", "无 settings.flags.write 权限", "denied") + deny(c, "无 settings.flags.write 权限") + return + } + + key := c.Param("key") + var req flagPatchReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + + var ff models.FeatureFlag + if err := s.db.Where("key = ?", key).First(&ff).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "开关不存在") + return + } + if ff.RequireReason && strings.TrimSpace(req.Reason) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "该开关修改必须提供 reason") + return + } + + before := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled) + old := ff.Enabled + ff.Enabled = req.Enabled + ff.UpdatedBy = u.UserID + if err := s.db.Save(&ff).Error; err != nil { + s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, "", err.Error(), "failed") + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") + return + } + + after := fmt.Sprintf(`{"enabled":%v}`, ff.Enabled) + h := models.FeatureFlagHistory{FlagKey: key, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")} + _ = s.db.Create(&h).Error + s.writeAuditResult(u.UserID, "settings.flag.update", "feature_flag", key, before, after, req.Reason, "success") + + respondOK(c, "success", gin.H{"key": key, "old": old, "new": req.Enabled}) +} + +func sanitizeJSON(raw string) string { + if strings.TrimSpace(raw) == "" { + return "{}" + } + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + return "{}" + } + for k := range m { + lk := strings.ToLower(k) + if strings.Contains(lk, "token") || strings.Contains(lk, "secret") || strings.Contains(lk, "key") || strings.Contains(lk, "password") { + m[k] = "***" + } + } + b, _ := json.Marshal(m) + return string(b) +} + +func isMaskedSecretsPayload(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return false + } + var walk func(any) bool + walk = func(x any) bool { + switch t := x.(type) { + case map[string]any: + if len(t) == 0 { + return false + } + allMasked := true + for _, vv := range t { + if !walk(vv) { + allMasked = false + break + } + } + return allMasked + case []any: + if len(t) == 0 { + return false + } + for _, vv := range t { + if !walk(vv) { + return false + } + } + return true + case string: + return strings.TrimSpace(t) == "***" + default: + return false + } + } + return walk(v) +} + +func (s *WebServer) handleChannelsList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.read", "无 channels.read 权限") { + return + } + + var items []models.ChannelConfig + s.db.Order("platform asc").Find(&items) + + type out struct { + ID uint `json:"id"` + Platform string `json:"platform"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Status string `json:"status"` + ConfigJSON string `json:"config_json"` + DraftConfigJSON string `json:"draft_config_json"` + Secrets string `json:"secrets"` + DraftSecrets string `json:"draft_secrets"` + HasDraft bool `json:"has_draft"` + PublishedAt *time.Time `json:"published_at"` + LastCheck *time.Time `json:"last_check_at"` + UpdatedAt time.Time `json:"updated_at"` + } + + resp := make([]out, 0, len(items)) + for _, it := range items { + sec := channel.MaybeDecryptPublic(it.SecretJSON) + draftSec := channel.MaybeDecryptPublic(it.DraftSecretJSON) + resp = append(resp, out{ + ID: it.ID, + Platform: it.Platform, + Name: it.Name, + Enabled: it.Enabled, + Status: it.Status, + ConfigJSON: it.ConfigJSON, + DraftConfigJSON: it.DraftConfigJSON, + Secrets: sanitizeJSON(sec), + DraftSecrets: sanitizeJSON(draftSec), + HasDraft: strings.TrimSpace(it.DraftConfigJSON) != "" || strings.TrimSpace(it.DraftSecretJSON) != "", + PublishedAt: it.PublishedAt, + LastCheck: it.LastCheck, + UpdatedAt: it.UpdatedAt, + }) + } + respondOK(c, "ok", gin.H{"channels": resp}) +} + +func (s *WebServer) handleChannelPatch(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var req channelConfigPatchReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") + return + } + + before := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`, + sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON))) + + if req.Name != nil { + row.Name = strings.TrimSpace(*req.Name) + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if len(req.Config) > 0 { + row.DraftConfigJSON = string(req.Config) + } + if len(req.Secrets) > 0 { + if isMaskedSecretsPayload(req.Secrets) { + // 前端脱敏占位符(***)不应覆盖真实密钥 + } else { + row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets)) + } + } + + if err := s.db.Save(&row).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + + after := fmt.Sprintf(`{"draft_config":%s,"draft_secrets":%s}`, + sanitizeJSON(row.DraftConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON))) + s.writeAudit(u.UserID, "channel_draft_update", "channel", row.Platform, before, after, "") + + respondOK(c, "success", gin.H{"mode": "draft"}) +} + +func (s *WebServer) handleChannelPublish(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") + return + } + + before := fmt.Sprintf(`{"config":%s,"secrets":%s}`, + sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON))) + + if strings.TrimSpace(row.DraftConfigJSON) != "" { + row.ConfigJSON = row.DraftConfigJSON + } + if strings.TrimSpace(row.DraftSecretJSON) != "" { + row.SecretJSON = row.DraftSecretJSON + } + now := time.Now() + row.PublishedAt = &now + row.UpdatedBy = u.UserID + if err := s.db.Save(&row).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "发布失败") + return + } + + after := fmt.Sprintf(`{"config":%s,"secrets":%s}`, + sanitizeJSON(row.ConfigJSON), sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON))) + s.writeAudit(u.UserID, "channel_publish", "channel", row.Platform, before, after, "") + + respondOK(c, "success", gin.H{"published_at": now}) +} + +func (s *WebServer) handleChannelReload(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + if s.reloadFn == nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置") + return + } + + detail, err := s.reloadFn() + if err != nil { + s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", "failed: "+err.Error()) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, err.Error()) + return + } + s.writeAudit(u.UserID, "channel_reload", "system", "runtime", "", "", detail) + respondOK(c, "success", gin.H{"detail": detail}) +} + +func (s *WebServer) handleChannelEnable(c *gin.Context) { + s.handleChannelToggle(c, true) +} + +func (s *WebServer) handleChannelDisable(c *gin.Context) { + s.handleChannelToggle(c, false) +} + +func (s *WebServer) handleChannelDisableAll(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + res := s.db.Model(&models.ChannelConfig{}).Where("enabled = ?", true).Updates(map[string]any{ + "enabled": false, + "status": "disabled", + "updated_by": u.UserID, + }) + if res.Error != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "批量关闭失败") + return + } + s.writeAudit(u.UserID, "channel_disable_all", "channel", "*", "", fmt.Sprintf(`{"affected":%d}`, res.RowsAffected), "") + respondOK(c, "success", gin.H{"affected": res.RowsAffected}) +} + +func (s *WebServer) handleChannelToggle(c *gin.Context, enable bool) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") + return + } + + before := fmt.Sprintf(`{"enabled":%v}`, row.Enabled) + row.Enabled = enable + if !enable { + row.Status = "disabled" + } + row.UpdatedBy = u.UserID + if err := s.db.Save(&row).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存失败") + return + } + after := fmt.Sprintf(`{"enabled":%v}`, row.Enabled) + action := "channel_disable" + if enable { + action = "channel_enable" + } + s.writeAudit(u.UserID, action, "channel", row.Platform, before, after, "") + respondOK(c, "success", gin.H{"enabled": row.Enabled, "platform": row.Platform}) +} + +func (s *WebServer) handleChannelTest(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.test", "无 channels.test 权限") { + return + } + + platform := c.Param("platform") + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在") + return + } + + if strings.TrimSpace(row.DraftConfigJSON) != "" { + row.ConfigJSON = row.DraftConfigJSON + } + if strings.TrimSpace(row.DraftSecretJSON) != "" { + row.SecretJSON = row.DraftSecretJSON + } + + now := time.Now() + status, detail := channel.TestChannelConnectivity(context.Background(), row) + row.LastCheck = &now + row.Status = status + if err := s.db.Save(&row).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "测试写入失败") + return + } + + s.writeAudit(u.UserID, "channel_test", "channel", row.Platform, "", fmt.Sprintf(`{"status":%q,"detail":%q}`, row.Status, detail), "manual test") + respondOK(c, "ok", gin.H{"status": row.Status, "detail": detail, "platform": row.Platform, "checked_at": now}) +} + +func (s *WebServer) handleChannelApply(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "channels.write", "无 channels.write 权限") { + return + } + + platform := c.Param("platform") + var req channelConfigPatchReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误(stage=patch,committed=false)") + return + } + + var row models.ChannelConfig + if err := s.db.Where("platform = ?", platform).First(&row).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "渠道不存在(stage=patch,committed=false)") + return + } + + beforeEnabled := row.Enabled + beforeConfig := row.ConfigJSON + beforeDraftConfig := row.DraftConfigJSON + beforeSecret := channel.MaybeDecryptPublic(row.SecretJSON) + beforeDraftSecret := channel.MaybeDecryptPublic(row.DraftSecretJSON) + + if req.Name != nil { + row.Name = strings.TrimSpace(*req.Name) + } + if req.Enabled != nil { + row.Enabled = *req.Enabled + } + if len(req.Config) > 0 { + row.DraftConfigJSON = string(req.Config) + } + if len(req.Secrets) > 0 { + if isMaskedSecretsPayload(req.Secrets) { + // 前端脱敏占位符(***)不应覆盖真实密钥 + } else { + row.DraftSecretJSON = channel.EncryptSecretJSON(string(req.Secrets)) + } + } + if !row.Enabled { + row.Status = "disabled" + } + if strings.TrimSpace(row.DraftConfigJSON) != "" { + row.ConfigJSON = row.DraftConfigJSON + } + if strings.TrimSpace(row.DraftSecretJSON) != "" { + row.SecretJSON = row.DraftSecretJSON + } + publishAt := time.Now() + row.PublishedAt = &publishAt + row.UpdatedBy = u.UserID + + if err := s.db.Save(&row).Error; err != nil { + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, "", "", "failed stage=publish: "+err.Error()) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "保存并发布失败(stage=publish,committed=false)") + return + } + + before := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`, + beforeEnabled, + sanitizeJSON(beforeConfig), + sanitizeJSON(beforeDraftConfig), + sanitizeJSON(beforeSecret), + sanitizeJSON(beforeDraftSecret), + ) + after := fmt.Sprintf(`{"enabled":%v,"config":%s,"draft_config":%s,"secrets":%s,"draft_secrets":%s}`, + row.Enabled, + sanitizeJSON(row.ConfigJSON), + sanitizeJSON(row.DraftConfigJSON), + sanitizeJSON(channel.MaybeDecryptPublic(row.SecretJSON)), + sanitizeJSON(channel.MaybeDecryptPublic(row.DraftSecretJSON)), + ) + + if s.reloadFn == nil { + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: reload 未配置") + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload 未配置(stage=reload,committed=true)") + return + } + detail, err := s.reloadFn() + if err != nil { + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, "failed stage=reload: "+err.Error()) + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "reload失败(stage=reload,committed=true): "+err.Error()) + return + } + + note := fmt.Sprintf("apply(patch+publish+reload) detail=%s", detail) + s.writeAudit(u.UserID, "channel_apply", "channel", row.Platform, before, after, note) + + respondOK(c, "success", gin.H{ + "platform": row.Platform, + "published_at": publishAt, + "detail": detail, + }) +} + +func (s *WebServer) handleAuditList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + + action := strings.TrimSpace(c.Query("action")) + targetType := strings.TrimSpace(c.Query("target_type")) + result := strings.TrimSpace(c.Query("result")) + actorID := strings.TrimSpace(c.Query("actor_id")) + from := strings.TrimSpace(c.Query("from")) + to := strings.TrimSpace(c.Query("to")) + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100")) + if limit <= 0 || limit > 500 { + limit = 100 + } + offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0")) + if offset < 0 { + offset = 0 + } + + q := s.db.Model(&models.AuditLog{}) + if action != "" { + q = q.Where("action = ?", action) + } + if targetType != "" { + q = q.Where("target_type = ?", targetType) + } + if actorID != "" { + if aid, err := strconv.ParseInt(actorID, 10, 64); err == nil { + q = q.Where("actor_id = ?", aid) + } + } + if from != "" { + if t, err := time.Parse(time.RFC3339, from); err == nil { + q = q.Where("created_at >= ?", t) + } + } + if to != "" { + if t, err := time.Parse(time.RFC3339, to); err == nil { + q = q.Where("created_at <= ?", t) + } + } + if result != "" { + q = q.Where("note LIKE ?", "%result="+result+"%") + } + + var logs []models.AuditLog + if err := q.Order("id desc").Limit(limit).Offset(offset).Find(&logs).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询失败") + return + } + respondOK(c, "ok", gin.H{"logs": logs}) +} + +func (s *WebServer) handleLegacyUsage(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + + type row struct { + Route string `json:"route"` + Count int64 `json:"count"` + } + routes := []string{"/api/records", "/delete/:id", "/export"} + usage := make([]row, 0, len(routes)) + for _, rt := range routes { + var cnt int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Count(&cnt).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy usage失败") + return + } + usage = append(usage, row{Route: rt, Count: cnt}) + } + + var recent []models.AuditLog + if err := s.db.Where("action = ? AND target_type = ?", "legacy.route.access", "route").Order("id desc").Limit(50).Find(&recent).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy recent失败") + return + } + respondOK(c, "ok", gin.H{"summary": usage, "recent": recent}) +} + +func (s *WebServer) handleLegacyTrend(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) + if days <= 0 || days > 90 { + days = 7 + } + start := time.Now().AddDate(0, 0, -days+1) + + type point struct { + Day string `json:"day"` + Count int64 `json:"count"` + } + type routeTrend struct { + Route string `json:"route"` + Points []point `json:"points"` + } + routes := []string{"/api/records", "/delete/:id", "/export"} + trends := make([]routeTrend, 0, len(routes)) + + for _, rt := range routes { + pts := make([]point, 0, days) + for i := 0; i < days; i++ { + dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i) + dayEnd := dayStart.Add(24 * time.Hour) + var cnt int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&cnt).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy trend失败") + return + } + pts = append(pts, point{Day: dayStart.Format("2006-01-02"), Count: cnt}) + } + trends = append(trends, routeTrend{Route: rt, Points: pts}) + } + respondOK(c, "ok", gin.H{"days": days, "from": start.Format(time.RFC3339), "trends": trends}) +} + +func (s *WebServer) handleLegacyReadiness(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "audit.read", "无 audit.read 权限") { + return + } + days, _ := strconv.Atoi(c.DefaultQuery("days", "7")) + if days <= 0 || days > 90 { + days = 7 + } + zeroDays, _ := strconv.Atoi(c.DefaultQuery("zero_days", "3")) + if zeroDays <= 0 || zeroDays > 30 { + zeroDays = 3 + } + + start := time.Now().AddDate(0, 0, -days+1) + routes := []string{"/api/records", "/delete/:id", "/export"} + routeTotals := map[string]int64{} + zeroStreak := map[string]int{} + windowTotal := int64(0) + ready := true + + for _, rt := range routes { + var total int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Where("created_at >= ?", start). + Count(&total).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败") + return + } + routeTotals[rt] = total + windowTotal += total + + streak := 0 + for i := days - 1; i >= 0; i-- { + dayStart := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, start.Location()).AddDate(0, 0, i) + dayEnd := dayStart.Add(24 * time.Hour) + var cnt int64 + err := s.db.Model(&models.AuditLog{}). + Where("action = ? AND target_type = ? AND target_id = ?", "legacy.route.access", "route", rt). + Where("created_at >= ? AND created_at < ?", dayStart, dayEnd). + Count(&cnt).Error + if err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询legacy readiness失败") + return + } + if cnt == 0 { + streak++ + } else { + break + } + } + zeroStreak[rt] = streak + if streak < zeroDays { + ready = false + } + } + + recommendation := "暂不建议下线 legacy 路由(未满足连续0调用阈值)" + if ready { + recommendation = "可考虑下线 legacy 路由(已满足连续0调用阈值)" + } + respondOK(c, "ok", gin.H{ + "days": days, + "zero_days": zeroDays, + "window_total": windowTotal, + "route_totals": routeTotals, + "consecutive_zero_days": zeroStreak, + "ready": ready, + "recommendation": recommendation, + }) +} + +func (s *WebServer) handleModulesList(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") { + return + } + type moduleItem struct { + Module string `json:"module"` + DisplayName string `json:"display_name"` + FlagKey string `json:"flag_key"` + Enabled bool `json:"enabled"` + } + items := []moduleItem{ + {Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"}, + {Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"}, + {Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"}, + } + for i := range items { + var ff models.FeatureFlag + if err := s.db.Where("key = ?", items[i].FlagKey).First(&ff).Error; err == nil { + items[i].Enabled = ff.Enabled + } + } + respondOK(c, "ok", gin.H{"modules": items}) +} + +func (s *WebServer) handleModuleToggle(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") { + return + } + module := strings.TrimSpace(strings.ToLower(c.Param("module"))) + flagKey := "" + switch module { + case "cpa": + flagKey = "enable_module_cpa" + case "cf": + flagKey = "enable_module_cf" + case "mail": + flagKey = "enable_module_mail" + default: + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module") + return + } + + var req moduleToggleReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + if strings.TrimSpace(req.Reason) == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "修改模块开关必须提供 reason") + return + } + if module == "cpa" && !req.Enabled { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "禁止禁用关键模块 cpa") + return + } + + var ff models.FeatureFlag + if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "模块开关不存在") + return + } + old := ff.Enabled + if old == req.Enabled { + respondOK(c, "noop", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled}) + return + } + ff.Enabled = req.Enabled + ff.UpdatedBy = u.UserID + if err := s.db.Save(&ff).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "更新失败") + return + } + _ = s.db.Create(&models.FeatureFlagHistory{FlagKey: flagKey, OldValue: old, NewValue: req.Enabled, ChangedBy: u.UserID, Reason: req.Reason, RequestID: c.GetHeader("X-Request-ID")}).Error + s.writeAuditResult(u.UserID, "module.toggle", "module", module, fmt.Sprintf(`{"enabled":%v}`, old), fmt.Sprintf(`{"enabled":%v}`, req.Enabled), req.Reason, "success") + respondOK(c, "success", gin.H{"module": module, "flag_key": flagKey, "old": old, "new": req.Enabled}) +} + +func (s *WebServer) handleDashboardOverview(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + + var jobs []models.OpsJob + s.db.Order("id desc").Limit(30).Find(&jobs) + statusCount := map[string]int{"pending": 0, "running": 0, "success": 0, "failed": 0, "cancelled": 0} + for _, j := range jobs { + statusCount[j.Status]++ + } + + type moduleItem struct { + Module string `json:"module"` + Enabled bool `json:"enabled"` + } + mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}} + for i := range mods { + flagKey := "enable_module_" + mods[i].Module + var ff models.FeatureFlag + if err := s.db.Where("key = ?", flagKey).First(&ff).Error; err == nil { + mods[i].Enabled = ff.Enabled + } + } + + var channels []models.ChannelConfig + s.db.Order("platform asc").Find(&channels) + channelOut := make([]gin.H, 0, len(channels)) + for _, ch := range channels { + channelOut = append(channelOut, gin.H{"platform": ch.Platform, "enabled": ch.Enabled, "status": ch.Status}) + } + + respondOK(c, "ok", gin.H{"jobs": gin.H{"recent": jobs, "status_count": statusCount}, "modules": mods, "channels": channelOut}) +} + +func (s *WebServer) handleOpsJobs(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 || limit > 200 { + limit = 50 + } + status := strings.TrimSpace(c.Query("status")) + target := strings.TrimSpace(c.Query("target")) + runbook := strings.TrimSpace(c.Query("runbook")) + requestID := strings.TrimSpace(c.Query("request_id")) + operator := strings.TrimSpace(c.Query("operator")) + riskLevel := strings.TrimSpace(c.Query("risk_level")) + qtext := strings.TrimSpace(c.Query("q")) + from := strings.TrimSpace(c.Query("from")) + to := strings.TrimSpace(c.Query("to")) + + q := s.db.Model(&models.OpsJob{}) + if status != "" { + q = q.Where("status = ?", status) + } + if target != "" { + q = q.Where("target = ?", target) + } + if runbook != "" { + q = q.Where("runbook = ?", runbook) + } + if requestID != "" { + q = q.Where("request_id = ?", requestID) + } + if operator != "" { + opid, err := strconv.ParseInt(operator, 10, 64) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid operator") + return + } + q = q.Where("operator = ?", opid) + } + if riskLevel != "" { + q = q.Where("risk_level = ?", riskLevel) + } + if qtext != "" { + like := "%" + qtext + "%" + q = q.Where("command LIKE ? OR runbook LIKE ? OR target LIKE ? OR request_id LIKE ?", like, like, like, like) + } + if from != "" { + t, err := time.Parse(time.RFC3339, from) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid from, must be RFC3339") + return + } + q = q.Where("created_at >= ?", t) + } + if to != "" { + t, err := time.Parse(time.RFC3339, to) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid to, must be RFC3339") + return + } + q = q.Where("created_at <= ?", t) + } + + var jobs []models.OpsJob + if err := q.Order("id desc").Limit(limit).Find(&jobs).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败") + return + } + respondOK(c, "ok", gin.H{"jobs": jobs, "filters": gin.H{"limit": limit, "status": status, "target": target, "runbook": runbook, "request_id": requestID, "operator": operator, "risk_level": riskLevel, "q": qtext, "from": from, "to": to}}) +} + +func (s *WebServer) handleDashboardSummary(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + + var total, running, failed, success int64 + s.db.Model(&models.OpsJob{}).Count(&total) + s.db.Model(&models.OpsJob{}).Where("status = ?", "running").Count(&running) + 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} + for k := range mods { + var ff models.FeatureFlag + if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil { + mods[k] = ff.Enabled + } + } + + respondOK(c, "ok", gin.H{ + "jobs": gin.H{"total": total, "running": running, "failed": failed, "success": success}, + "modules": mods, + }) +} + +func (s *WebServer) handleOpsJobsByRequestID(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + requestID := strings.TrimSpace(c.Param("requestID")) + if requestID == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "request id required") + return + } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 || limit > 200 { + limit = 50 + } + var jobs []models.OpsJob + if err := s.db.Where("request_id = ?", requestID).Order("id desc").Limit(limit).Find(&jobs).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs失败") + return + } + var total int64 + if err := s.db.Model(&models.OpsJob{}).Where("request_id = ?", requestID).Count(&total).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "查询ops jobs总数失败") + return + } + respondOK(c, "ok", gin.H{"request_id": requestID, "total": total, "jobs": jobs}) +} + +func (s *WebServer) handleOpsJobDetail(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.read", "无 ops.read 权限") { + return + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") + return + } + + var job models.OpsJob + if err := s.db.First(&job, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") + return + } + + var steps []models.OpsJobStep + if err := s.db.Where("job_id = ?", job.ID).Order("id asc").Find(&steps).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "query steps failed") + return + } + + stepStats := map[string]int{"running": 0, "success": 0, "failed": 0, "skipped": 0} + var stepDurationMs int64 + for _, st := range steps { + stepStats[st.Status]++ + if !st.StartedAt.IsZero() && !st.EndedAt.IsZero() { + d := st.EndedAt.Sub(st.StartedAt).Milliseconds() + if d > 0 { + stepDurationMs += d + } + } + } + var jobDurationMs int64 + if !job.StartedAt.IsZero() && !job.EndedAt.IsZero() { + d := job.EndedAt.Sub(job.StartedAt).Milliseconds() + if d > 0 { + jobDurationMs = d + } + } + respondOK(c, "ok", gin.H{"job": job, "steps": steps, "step_stats": stepStats, "step_total": len(steps), "duration": gin.H{"job_ms": jobDurationMs, "steps_ms_sum": stepDurationMs}}) +} + +func (s *WebServer) handleOpsJobCancel(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.cancel", "无 ops.cancel 权限") { + return + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") + return + } + + var req opsJobActionReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + reason := strings.TrimSpace(req.Reason) + if reason == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cancel 必须提供 reason") + return + } + + var job models.OpsJob + if err := s.db.First(&job, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") + return + } + if job.Status == "success" || job.Status == "failed" || job.Status == "cancelled" { + respondOK(c, "noop", gin.H{"id": job.ID, "job_status": job.Status, "reason": "job already finished"}) + return + } + if job.Status != "pending" && job.Status != "running" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "job status not cancellable") + return + } + cancelled := runbook.CancelJob(job.ID) + job.Status = "cancelled" + job.CancelNote = reason + job.EndedAt = time.Now() + if err := s.db.Save(&job).Error; err != nil { + respondErr(c, http.StatusInternalServerError, ecode.ErrStepFailed, "cancel failed") + return + } + s.writeAuditResult(u.UserID, "ops.job.cancel", "ops_job", strconv.Itoa(int(job.ID)), "", "", reason, "success") + respondOK(c, "cancelled", gin.H{"id": job.ID, "reason": reason, "signal_sent": cancelled}) +} + +func (s *WebServer) handleOpsJobRetry(c *gin.Context) { + u := currentUser(c) + if !s.requirePerm(c, u, "ops.retry", "无 ops.retry 权限") { + return + } + id, err := strconv.Atoi(c.Param("id")) + if err != nil || id <= 0 { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "invalid job id") + return + } + + var req opsJobActionReq + if err := c.ShouldBindJSON(&req); err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误") + return + } + reason := strings.TrimSpace(req.Reason) + if reason == "" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "retry 必须提供 reason") + return + } + + var old models.OpsJob + if err := s.db.First(&old, id).Error; err != nil { + respondErr(c, http.StatusNotFound, ecode.ErrStepFailed, "job not found") + return + } + if strings.TrimSpace(old.Status) != "failed" { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "only failed jobs can retry") + return + } + + newID, err := ops.RetryJobWithDB(s.db, filepath.Clean(s.baseDir), uint(id)) + if err != nil { + respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error()) + return + } + s.writeAuditResult(u.UserID, "ops.job.retry", "ops_job", strconv.Itoa(id), "", "", reason, "success") + respondOK(c, "retried", gin.H{"old_job_id": id, "new_job_id": newID, "reason": reason}) +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..d4f32b3 --- /dev/null +++ b/models/models.go @@ -0,0 +1,336 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type Transaction struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID int64 `json:"user_id"` + Amount int64 `json:"amount"` // 金额,单位:分 + Category string `gorm:"size:50" json:"category"` + Note string `json:"note"` + Date string `gorm:"size:20;index" json:"date"` + IsDeleted bool `gorm:"default:false" json:"is_deleted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CategoryKeyword struct { + ID uint `gorm:"primaryKey"` + Keyword string `gorm:"uniqueIndex;size:50"` + Category string `gorm:"size:50"` +} + +// FeatureFlag 高风险能力开关(默认关闭) +type FeatureFlag struct { + ID uint `gorm:"primaryKey" json:"id"` + Key string `gorm:"uniqueIndex;size:100" json:"key"` + Enabled bool `gorm:"default:false" json:"enabled"` + RiskLevel string `gorm:"size:20" json:"risk_level"` // low|medium|high + Description string `gorm:"size:255" json:"description"` + RequireReason bool `gorm:"default:false" json:"require_reason"` + UpdatedBy int64 `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + 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"` +} + +// FeatureFlagHistory 开关变更历史 +type FeatureFlagHistory struct { + ID uint `gorm:"primaryKey" json:"id"` + FlagKey string `gorm:"index;size:100" json:"flag_key"` + OldValue bool `json:"old_value"` + NewValue bool `json:"new_value"` + ChangedBy int64 `json:"changed_by"` + Reason string `gorm:"size:255" json:"reason"` + RequestID string `gorm:"size:100" json:"request_id"` + CreatedAt time.Time `json:"created_at"` +} + +// ChannelConfig 渠道接入配置(平台适配层参数) +type ChannelConfig struct { + ID uint `gorm:"primaryKey" json:"id"` + Platform string `gorm:"uniqueIndex;size:32" json:"platform"` // qqbot_official|telegram|feishu + Name string `gorm:"size:64" json:"name"` + Enabled bool `gorm:"default:false" json:"enabled"` + Status string `gorm:"size:20;default:'disabled'" json:"status"` // ok|error|disabled + ConfigJSON string `gorm:"type:text" json:"config_json"` // 生效配置 JSON + SecretJSON string `gorm:"type:text" json:"-"` // 生效密钥 JSON(建议加密) + DraftConfigJSON string `gorm:"type:text" json:"draft_config_json"` // 草稿配置 JSON + DraftSecretJSON string `gorm:"type:text" json:"-"` // 草稿密钥 JSON(建议加密) + LastCheck *time.Time `json:"last_check_at"` + PublishedAt *time.Time `json:"published_at"` + UpdatedBy int64 `json:"updated_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AuditLog 通用审计日志 +type AuditLog struct { + ID uint `gorm:"primaryKey" json:"id"` + ActorID int64 `gorm:"index" json:"actor_id"` + Action string `gorm:"size:64;index" json:"action"` + TargetType string `gorm:"size:64;index" json:"target_type"` + TargetID string `gorm:"size:128;index" json:"target_id"` + BeforeJSON string `gorm:"type:text" json:"before_json"` + AfterJSON string `gorm:"type:text" json:"after_json"` + Note string `gorm:"size:255" json:"note"` + CreatedAt time.Time `json:"created_at"` +} + +// MessageDedup 入站事件幂等去重 +type MessageDedup struct { + ID uint `gorm:"primaryKey" json:"id"` + Platform string `gorm:"size:32;index:idx_platform_event,unique" json:"platform"` + EventID string `gorm:"size:128;index:idx_platform_event,unique" json:"event_id"` + ProcessedAt time.Time `json:"processed_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 +} + +func seedDefaultFeatureFlags(db *gorm.DB) error { + defaults := []FeatureFlag{ + {Key: "allow_cross_user_read", Enabled: false, RiskLevel: "high", Description: "允许读取非本人账本数据", RequireReason: true}, + {Key: "allow_cross_user_delete", Enabled: false, RiskLevel: "high", Description: "允许删除非本人账本记录", RequireReason: true}, + {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}, + } + + for _, ff := range defaults { + if err := db.Where("key = ?", ff.Key).FirstOrCreate(&ff).Error; err != nil { + return err + } + } + 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"}, + } + 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: "{}"}, + {Platform: "telegram", Name: "Telegram Bot", Enabled: false, Status: "disabled", ConfigJSON: "{}", SecretJSON: "{}", DraftConfigJSON: "{}", DraftSecretJSON: "{}"}, + {Platform: "feishu", Name: "飞书 Bot", Enabled: false, Status: "disabled", ConfigJSON: "{}", SecretJSON: "{}", DraftConfigJSON: "{}", DraftSecretJSON: "{}"}, + } + + for _, ch := range defaults { + if err := db.Where("platform = ?", ch.Platform).FirstOrCreate(&ch).Error; err != nil { + return err + } + } + return nil +} + +// Migrate 自动迁移数据库表结构并初始化分类关键词 +func Migrate(db *gorm.DB) error { + if err := db.AutoMigrate( + &Transaction{}, + &CategoryKeyword{}, + &FeatureFlag{}, + &FeatureFlagHistory{}, + &ChannelConfig{}, + &AuditLog{}, + &MessageDedup{}, + &OpsTarget{}, + &OpsJob{}, + &OpsJobStep{}, + &AppSetting{}, + ); err != nil { + return err + } + + if err := seedDefaultFeatureFlags(db); err != nil { + return err + } + + if err := seedDefaultChannels(db); err != nil { + return err + } + if err := seedDefaultAppSettings(db); err != nil { + return err + } + + // 检查是否已有关键词数据 + var count int64 + db.Model(&CategoryKeyword{}).Count(&count) + if count > 0 { + return nil + } + + // 预设分类关键词 + keywords := []CategoryKeyword{ + // 餐饮 + {Keyword: "早餐", Category: "餐饮"}, {Keyword: "午餐", Category: "餐饮"}, {Keyword: "晚餐", Category: "餐饮"}, + {Keyword: "早饭", Category: "餐饮"}, {Keyword: "午饭", Category: "餐饮"}, {Keyword: "晚饭", Category: "餐饮"}, + {Keyword: "吃饭", Category: "餐饮"}, {Keyword: "吃", Category: "餐饮"}, {Keyword: "饭", Category: "餐饮"}, + {Keyword: "面", Category: "餐饮"}, {Keyword: "粉", Category: "餐饮"}, {Keyword: "粥", Category: "餐饮"}, + {Keyword: "火锅", Category: "餐饮"}, {Keyword: "烧烤", Category: "餐饮"}, {Keyword: "烤肉", Category: "餐饮"}, + {Keyword: "外卖", Category: "餐饮"}, {Keyword: "点餐", Category: "餐饮"}, {Keyword: "宵夜", Category: "餐饮"}, + {Keyword: "夜宵", Category: "餐饮"}, {Keyword: "小吃", Category: "餐饮"}, {Keyword: "快餐", Category: "餐饮"}, + {Keyword: "饺子", Category: "餐饮"}, {Keyword: "面条", Category: "餐饮"}, {Keyword: "米饭", Category: "餐饮"}, + {Keyword: "菜", Category: "餐饮"}, {Keyword: "肉", Category: "餐饮"}, {Keyword: "鱼", Category: "餐饮"}, + {Keyword: "鸡", Category: "餐饮"}, {Keyword: "蛋", Category: "餐饮"}, {Keyword: "汤", Category: "餐饮"}, + {Keyword: "麻辣烫", Category: "餐饮"}, {Keyword: "炒饭", Category: "餐饮"}, {Keyword: "盖饭", Category: "餐饮"}, + {Keyword: "包子", Category: "餐饮"}, {Keyword: "馒头", Category: "餐饮"}, {Keyword: "饼", Category: "餐饮"}, + {Keyword: "食堂", Category: "餐饮"}, {Keyword: "餐厅", Category: "餐饮"}, {Keyword: "饭店", Category: "餐饮"}, + {Keyword: "美团", Category: "餐饮"}, {Keyword: "饿了么", Category: "餐饮"}, + + // 交通 + {Keyword: "打车", Category: "交通"}, {Keyword: "车费", Category: "交通"}, {Keyword: "出租车", Category: "交通"}, + {Keyword: "滴滴", Category: "交通"}, {Keyword: "公交", Category: "交通"}, {Keyword: "地铁", Category: "交通"}, + {Keyword: "高铁", Category: "交通"}, {Keyword: "火车", Category: "交通"}, {Keyword: "飞机", Category: "交通"}, + {Keyword: "机票", Category: "交通"}, {Keyword: "车票", Category: "交通"}, {Keyword: "船票", Category: "交通"}, + {Keyword: "加油", Category: "交通"}, {Keyword: "油费", Category: "交通"}, {Keyword: "停车", Category: "交通"}, + {Keyword: "停车费", Category: "交通"}, {Keyword: "过路费", Category: "交通"}, {Keyword: "高速", Category: "交通"}, + {Keyword: "骑车", Category: "交通"}, {Keyword: "单车", Category: "交通"}, {Keyword: "共享", Category: "交通"}, + {Keyword: "顺风车", Category: "交通"}, {Keyword: "快车", Category: "交通"}, {Keyword: "专车", Category: "交通"}, + {Keyword: "拼车", Category: "交通"}, {Keyword: "出行", Category: "交通"}, {Keyword: "通勤", Category: "交通"}, + + // 购物 + {Keyword: "买", Category: "购物"}, {Keyword: "购物", Category: "购物"}, {Keyword: "淘宝", Category: "购物"}, + {Keyword: "京东", Category: "购物"}, {Keyword: "拼多多", Category: "购物"}, {Keyword: "网购", Category: "购物"}, + {Keyword: "超市", Category: "购物"}, {Keyword: "商场", Category: "购物"}, {Keyword: "衣服", Category: "购物"}, + {Keyword: "鞋", Category: "购物"}, {Keyword: "裤子", Category: "购物"}, {Keyword: "裙子", Category: "购物"}, + {Keyword: "包", Category: "购物"}, {Keyword: "手机", Category: "购物"}, {Keyword: "电脑", Category: "购物"}, + {Keyword: "日用品", Category: "购物"}, {Keyword: "生活用品", Category: "购物"}, + + // 饮品 + {Keyword: "咖啡", Category: "饮品"}, {Keyword: "奶茶", Category: "饮品"}, {Keyword: "茶", Category: "饮品"}, + {Keyword: "饮料", Category: "饮品"}, {Keyword: "水", Category: "饮品"}, {Keyword: "果汁", Category: "饮品"}, + {Keyword: "星巴克", Category: "饮品"}, {Keyword: "瑞幸", Category: "饮品"}, {Keyword: "喜茶", Category: "饮品"}, + {Keyword: "蜜雪", Category: "饮品"}, {Keyword: "可乐", Category: "饮品"}, {Keyword: "啤酒", Category: "饮品"}, + {Keyword: "酒", Category: "饮品"}, {Keyword: "牛奶", Category: "饮品"}, + + // 水果 + {Keyword: "水果", Category: "水果"}, {Keyword: "苹果", Category: "水果"}, {Keyword: "香蕉", Category: "水果"}, + {Keyword: "橘子", Category: "水果"}, {Keyword: "橙子", Category: "水果"}, {Keyword: "葡萄", Category: "水果"}, + {Keyword: "西瓜", Category: "水果"}, {Keyword: "草莓", Category: "水果"}, {Keyword: "芒果", Category: "水果"}, + + // 零食 + {Keyword: "零食", Category: "零食"}, {Keyword: "薯片", Category: "零食"}, {Keyword: "糖", Category: "零食"}, + {Keyword: "巧克力", Category: "零食"}, {Keyword: "饼干", Category: "零食"}, {Keyword: "面包", Category: "零食"}, + {Keyword: "蛋糕", Category: "零食"}, {Keyword: "甜品", Category: "零食"}, {Keyword: "甜点", Category: "零食"}, + + // 住房 + {Keyword: "房租", Category: "住房"}, {Keyword: "租房", Category: "住房"}, {Keyword: "水电", Category: "住房"}, + {Keyword: "电费", Category: "住房"}, {Keyword: "水费", Category: "住房"}, {Keyword: "燃气", Category: "住房"}, + {Keyword: "物业", Category: "住房"}, {Keyword: "宽带", Category: "住房"}, {Keyword: "网费", Category: "住房"}, + + // 通讯 + {Keyword: "话费", Category: "通讯"}, {Keyword: "流量", Category: "通讯"}, {Keyword: "充值", Category: "通讯"}, + {Keyword: "手机费", Category: "通讯"}, + + // 医疗 + {Keyword: "看病", Category: "医疗"}, {Keyword: "药", Category: "医疗"}, {Keyword: "医院", Category: "医疗"}, + {Keyword: "挂号", Category: "医疗"}, {Keyword: "体检", Category: "医疗"}, {Keyword: "医疗", Category: "医疗"}, + {Keyword: "门诊", Category: "医疗"}, {Keyword: "牙", Category: "医疗"}, + + // 娱乐 + {Keyword: "电影", Category: "娱乐"}, {Keyword: "游戏", Category: "娱乐"}, {Keyword: "KTV", Category: "娱乐"}, + {Keyword: "唱歌", Category: "娱乐"}, {Keyword: "旅游", Category: "娱乐"}, {Keyword: "景点", Category: "娱乐"}, + {Keyword: "门票", Category: "娱乐"}, {Keyword: "健身", Category: "娱乐"}, {Keyword: "运动", Category: "娱乐"}, + {Keyword: "会员", Category: "娱乐"}, {Keyword: "VIP", Category: "娱乐"}, + + // 教育 + {Keyword: "书", Category: "教育"}, {Keyword: "课", Category: "教育"}, {Keyword: "培训", Category: "教育"}, + {Keyword: "学费", Category: "教育"}, {Keyword: "考试", Category: "教育"}, {Keyword: "学习", Category: "教育"}, + + // 烟酒 + {Keyword: "烟", Category: "烟酒"}, {Keyword: "香烟", Category: "烟酒"}, {Keyword: "白酒", Category: "烟酒"}, + {Keyword: "红酒", Category: "烟酒"}, + + // 红包/转账 + {Keyword: "红包", Category: "红包"}, {Keyword: "转账", Category: "转账"}, {Keyword: "借", Category: "转账"}, + {Keyword: "还钱", Category: "转账"}, + + // 宠物 + {Keyword: "猫粮", Category: "宠物"}, {Keyword: "狗粮", Category: "宠物"}, {Keyword: "宠物", Category: "宠物"}, + } + + return db.CreateInBatches(keywords, 50).Error +} diff --git a/runbooks/cf_dns_add.yaml b/runbooks/cf_dns_add.yaml new file mode 100644 index 0000000..2476418 --- /dev/null +++ b/runbooks/cf_dns_add.yaml @@ -0,0 +1,17 @@ +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 + required_paths: + - "success" diff --git a/runbooks/cf_dns_del.yaml b/runbooks/cf_dns_del.yaml new file mode 100644 index 0000000..addce11 --- /dev/null +++ b/runbooks/cf_dns_del.yaml @@ -0,0 +1,17 @@ +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 + required_paths: + - "success" diff --git a/runbooks/cf_dns_list.yaml b/runbooks/cf_dns_list.yaml new file mode 100644 index 0000000..e48c1c2 --- /dev/null +++ b/runbooks/cf_dns_list.yaml @@ -0,0 +1,17 @@ +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 + required_paths: + - "success" diff --git a/runbooks/cf_dns_proxy.yaml b/runbooks/cf_dns_proxy.yaml new file mode 100644 index 0000000..61e423f --- /dev/null +++ b/runbooks/cf_dns_proxy.yaml @@ -0,0 +1,30 @@ +version: 1 +name: cf_dns_proxy +description: 修改 DNS 代理开关(按 record_id 或 name) +steps: + - id: resolve_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_NAME=${env.INPUT_NAME} python3 - <<'PY'\nimport os,requests,json\nrec=os.getenv('INPUT_RECORD_ID','').strip()\nname=os.getenv('INPUT_NAME','').strip()\nif rec=='__empty__':\n rec=''\nif name=='__empty__':\n name=''\ntoken=os.getenv('CF_API_TOKEN','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\nresp=requests.get('https://api.cloudflare.com/client/v4/zones?per_page=200', headers=headers, timeout=15)\nresp.raise_for_status()\nfor z in resp.json().get('result',[]):\n zid=z.get('id')\n if rec:\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') and data.get('result'):\n out=data.get('result')\n out['_zone_id']=zid\n print(json.dumps({'success':True,'result':out}))\n raise SystemExit(0)\n continue\n if name:\n r=requests.get(f'https://api.cloudflare.com/client/v4/zones/{zid}/dns_records', headers=headers, params={'name': name, 'per_page': 100}, timeout=15)\n if r.status_code==200:\n data=r.json()\n if data.get('success') and data.get('result'):\n rec0=data['result'][0]\n rec0['_zone_id']=zid\n print(json.dumps({'success':True,'result':rec0}))\n raise SystemExit(0)\nprint(json.dumps({'success':False,'errors':['record_not_found']}))\nPY" + - id: assert_resolve + action: assert.json + on_fail: stop + with: + source_step: resolve_dns + required_paths: + - "success" + - id: update_dns + action: ssh.exec + on_fail: stop + with: + target: hwsg + command: "CF_API_TOKEN=${env_cf_api_token} INPUT_PROXIED=${env.INPUT_PROXIED} INPUT_JSON='${steps.resolve_dns.output}' python3 - <<'PY'\nimport os,requests,json\nproxied=os.getenv('INPUT_PROXIED','false').lower()=='true'\ntoken=os.getenv('CF_API_TOKEN','')\nheaders={'Authorization':'Bearer '+token,'Content-Type':'application/json'}\nraw=os.getenv('INPUT_JSON','')\ntry:\n data=json.loads(raw)\nexcept Exception:\n data={}\nres=data.get('result') or {}\nzone_id=res.get('_zone_id')\nrec_id=res.get('id')\nif not zone_id or not rec_id:\n print(json.dumps({'success':False,'errors':['record_not_found']}))\n raise SystemExit(1)\npayload={\n 'type': res.get('type'),\n 'name': res.get('name'),\n 'content': res.get('content'),\n 'proxied': proxied\n}\nresp=requests.put(f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records/{rec_id}', headers=headers, json=payload, timeout=15)\nprint(resp.text)\nPY" + - id: assert_update + action: assert.json + on_fail: stop + with: + source_step: update_dns + required_paths: + - "success" diff --git a/runbooks/cf_dns_set.yaml b/runbooks/cf_dns_set.yaml new file mode 100644 index 0000000..352ce26 --- /dev/null +++ b/runbooks/cf_dns_set.yaml @@ -0,0 +1,30 @@ +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 + 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 + required_paths: + - "success" diff --git a/runbooks/cf_dns_update.yaml b/runbooks/cf_dns_update.yaml new file mode 100644 index 0000000..5ad9266 --- /dev/null +++ b/runbooks/cf_dns_update.yaml @@ -0,0 +1,17 @@ +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 + required_paths: + - "success" diff --git a/runbooks/cf_status.yaml b/runbooks/cf_status.yaml new file mode 100644 index 0000000..1c49ac6 --- /dev/null +++ b/runbooks/cf_status.yaml @@ -0,0 +1,10 @@ +version: 1 +name: cf_status +description: CF 模块状态检查占位 runbook(不执行外部操作) +inputs: [] +steps: + - id: cf_noop + action: sleep + on_fail: stop + with: + ms: 10 diff --git a/runbooks/cf_workers_list.yaml b/runbooks/cf_workers_list.yaml new file mode 100644 index 0000000..ebd8518 --- /dev/null +++ b/runbooks/cf_workers_list.yaml @@ -0,0 +1,17 @@ +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 + required_paths: + - "success" diff --git a/runbooks/cf_zones.yaml b/runbooks/cf_zones.yaml new file mode 100644 index 0000000..d0ecdc6 --- /dev/null +++ b/runbooks/cf_zones.yaml @@ -0,0 +1,17 @@ +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 + required_paths: + - "success" diff --git a/runbooks/cpa_status.yaml b/runbooks/cpa_status.yaml new file mode 100644 index 0000000..8d87ba8 --- /dev/null +++ b/runbooks/cpa_status.yaml @@ -0,0 +1,19 @@ +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 + required_paths: + - "usage.total_requests" + - "usage.total_tokens" diff --git a/runbooks/cpa_usage_backup.yaml b/runbooks/cpa_usage_backup.yaml new file mode 100644 index 0000000..d36361c --- /dev/null +++ b/runbooks/cpa_usage_backup.yaml @@ -0,0 +1,38 @@ +version: 1 +name: cpa_usage_backup +description: 实时导出 usage 并打包备份(公网管理接口) +inputs: [] +steps: + - id: export_and_package + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + ts=$(date +%F_%H%M%S) + out=/root/cliproxyapi/usage_export_${ts}.json + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage/export -o ${out} + + echo ${out} + + latest=$(ls -1t /root/cliproxyapi/usage_export_*.json | head -n 1) + ts=$(date +%Y-%m-%d_%H%M%S) + out=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.tar.gz + meta=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.meta.txt + mkdir -p /root/backups/cpa-runtime-daily + tar -czf ${out} ${latest} + sha=$(sha256sum ${out} | awk '{print $1}') + size=$(du -h ${out} | awk '{print $1}') + req=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_requests', data.get('total_requests','unknown')))" ) + tok=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_tokens', data.get('total_tokens','unknown')))" ) + { + echo "time=$(date '+%F %T %z')" + echo "source=${latest}" + echo "backup=${out}" + echo "sha256=${sha}" + echo "size=${size}" + echo "total_requests=${req}" + echo "total_tokens=${tok}" + } > ${meta} + cat ${meta} diff --git a/runbooks/cpa_usage_restore.yaml b/runbooks/cpa_usage_restore.yaml new file mode 100644 index 0000000..0f91478 --- /dev/null +++ b/runbooks/cpa_usage_restore.yaml @@ -0,0 +1,112 @@ +version: 1 +name: cpa_usage_restore +description: 从备份包恢复 usage(公网管理接口,双重校验) +inputs: + - backup_id +steps: + - id: pre_backup + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + ts=$(date +%F_%H%M%S) + out=/root/cliproxyapi/usage_export_${ts}.json + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage/export -o ${out} + + echo ${out} + + latest=$(ls -1t /root/cliproxyapi/usage_export_*.json | head -n 1) + ts=$(date +%Y-%m-%d_%H%M%S) + out=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.tar.gz + meta=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.meta.txt + mkdir -p /root/backups/cpa-runtime-daily + tar -czf ${out} ${latest} + sha=$(sha256sum ${out} | awk '{print $1}') + size=$(du -h ${out} | awk '{print $1}') + req=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_requests', data.get('total_requests','unknown')))" ) + tok=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_tokens', data.get('total_tokens','unknown')))" ) + { + echo "time=$(date '+%F %T %z')" + echo "source=${latest}" + echo "backup=${out}" + echo "sha256=${sha}" + echo "size=${size}" + echo "total_requests=${req}" + echo "total_tokens=${tok}" + } > ${meta} + cat ${meta} + + - id: find_backup + action: shell.exec + on_fail: stop + with: + command: "ls -1 /root/backups/cpa-runtime-daily/${inputs.backup_id}.tar.gz" + + - id: extract_backup + action: shell.exec + on_fail: stop + with: + command: "mkdir -p /tmp/cpa-restore && tar -xzf /root/backups/cpa-runtime-daily/${inputs.backup_id}.tar.gz -C /tmp/cpa-restore" + + - id: import_usage + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + latest=$(ls -1 /tmp/cpa-restore/root/cliproxyapi/usage_export_*.json 2>/dev/null | head -n 1) + if [ -z "$latest" ]; then + latest=$(ls -1 /tmp/cpa-restore/root/cliproxyapi/stats_persistence-*.json 2>/dev/null | head -n 1) + fi + if [ -z "$latest" ]; then + echo "no usage file found"; exit 1 + fi + python3 -c "import json; json.load(open('${latest}','r',encoding='utf-8')); print('json_ok')" + resp=$(curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" -H "Content-Type: application/json" --data @${latest} ${CPA_BASE}/usage/import) + echo "$resp" + python3 -c "import json,sys; r=json.loads(sys.argv[1]); import sys as _s; _s.exit(r.get('error')) if isinstance(r,dict) and r.get('error') else print('import_ok')" "$resp" + + - id: verify_now + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage + + - id: verify_now_assert + action: assert.json + on_fail: stop + with: + source_step: verify_now + required_paths: + - "usage.total_requests" + - "usage.total_tokens" + + - id: wait_10s + action: sleep + on_fail: continue + with: + ms: 10000 + + - id: verify_later + action: shell.exec + on_fail: stop + with: + command: | + CPA_TOKEN=${env.cpa_management_token} + CPA_BASE=https://cpa.pao.xx.kg/v0/management + curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage + + - id: verify_later_assert + action: assert.json + on_fail: stop + with: + source_step: verify_later + required_paths: + - "usage.total_requests" + - "usage.total_tokens" diff --git a/runbooks/mail_status.yaml b/runbooks/mail_status.yaml new file mode 100644 index 0000000..f2148f8 --- /dev/null +++ b/runbooks/mail_status.yaml @@ -0,0 +1,10 @@ +version: 1 +name: mail_status +description: Mail 模块状态检查占位 runbook(不执行外部操作) +inputs: [] +steps: + - id: mail_noop + action: sleep + on_fail: stop + with: + ms: 10 diff --git a/templates/ai_settings.html b/templates/ai_settings.html new file mode 100644 index 0000000..312d8ea --- /dev/null +++ b/templates/ai_settings.html @@ -0,0 +1,145 @@ + + + + + +AI 配置 - Ops-Assistant + + + + +
+
+

AI 模型配置

+
+
+
+
+
+ 用于将“非命令文本”翻译为标准命令(仅翻译,不自动执行)。 +
+ + +
+ +
+
+ + + diff --git a/templates/audit.html b/templates/audit.html new file mode 100644 index 0000000..1716b4a --- /dev/null +++ b/templates/audit.html @@ -0,0 +1,211 @@ + + + + + +🧾 审计日志 - Ops-Assistant + + + +
+
+
🧾 审计日志
+
{{.version}}
+
+
+ + 返回首页 + 渠道配置 + 退出 +
+
+ +
+
+
操作类型
+
目标类型
+
结果
+
操作人ID
+
开始时间(RFC3339)
+
结束时间(RFC3339)
+
+
+ + +
+
+
+ + + + diff --git a/templates/cf_settings.html b/templates/cf_settings.html new file mode 100644 index 0000000..d775082 --- /dev/null +++ b/templates/cf_settings.html @@ -0,0 +1,80 @@ + + + + + +Cloudflare 配置 - Ops-Assistant + + + +
+
☁️ Cloudflare 配置
+ +
+
+
+

账号凭据

+
+
+ 用于查询/修改 DNS & Workers(单账号) +
+ + +
+ +
+
+ + + diff --git a/templates/channels.html b/templates/channels.html new file mode 100644 index 0000000..635aa1d --- /dev/null +++ b/templates/channels.html @@ -0,0 +1,413 @@ + + + + + +渠道配置 - Ops-Assistant + + + +
+
🦞 渠道配置中心(草稿/发布) · {{.version}}
+
+ + 返回首页 + 退出 +
+
+
+
+ + +
+
填写需要的参数即可(每项一个输入框)。高级 JSON 已折叠,默认不需要碰。
+
+
+ + + + diff --git a/templates/cpa_settings.html b/templates/cpa_settings.html new file mode 100644 index 0000000..233ca34 --- /dev/null +++ b/templates/cpa_settings.html @@ -0,0 +1,207 @@ + + + + + +CPA 配置 - Ops-Assistant + + + +
+
🔧 CPA 配置 · {{.version}}
+
+ + 返回首页 + 退出 +
+
+
+
+

CPA 管理接口

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

目标主机(Ops Targets)

+
+
+
+
+
+
+ +
+ + + +
NameHostPortUserEnabled操作
+
+
+
+ + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..48dcb40 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,257 @@ + + + + + +🛠️ Ops-Assistant + + + +
+
+

🛠️ Ops-Assistant

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

任务概览

+
Pending0
+
Running0
+
Success0
+
Failed0
+
Cancelled0
+ +
+ +
+

通道状态

+
+ +
+
+ +
+
最近任务
+
+
+
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..1eb3429 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,142 @@ + + + + + +🛠️ Ops-Assistant - 登录 + + + + + + diff --git a/templates/ops.html b/templates/ops.html new file mode 100644 index 0000000..5bbc03a --- /dev/null +++ b/templates/ops.html @@ -0,0 +1,229 @@ + + + + + +🛠️ OPS任务 - Ops-Assistant + + + +
+
🛠️ OPS任务面板 · {{.version}}
+
+ + 返回首页 + 退出 +
+
+
+
+ + + + + + + + + +
+
+
+ + + diff --git a/templates/ops_targets.html b/templates/ops_targets.html new file mode 100644 index 0000000..2e7238f --- /dev/null +++ b/templates/ops_targets.html @@ -0,0 +1,145 @@ + + + + + +目标主机配置 - Ops-Assistant + + + +
+
🎯 目标主机配置 · {{.version}}
+
+ + 返回首页 + 退出 +
+
+
+
+

新增目标

+
+
+
+
+
+
+
+ +
+

已有目标

+ + + +
NameHostPortUserEnabled操作
+
+
+ + + diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..5702549 --- /dev/null +++ b/version/version.go @@ -0,0 +1,22 @@ +package version + +import ( + "fmt" + "runtime" +) + +var ( + Version = "dev" + GitCommit = "unknown" + BuildTime = "unknown" + GoVersion = runtime.Version() +) + +func Info() string { + 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("Ops-Assistant %s", Version) +}