init: ops-assistant codebase
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
*.db
|
||||
config.yaml
|
||||
ops-assistant
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
docker-compose.yml
|
||||
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@@ -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/
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@@ -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"]
|
||||
110
Makefile
Normal file
110
Makefile
Normal file
@@ -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/^## / /'
|
||||
186
README.md
Normal file
186
README.md
Normal file
@@ -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
|
||||
138
cmd/main.go
Normal file
138
cmd/main.go
Normal file
@@ -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 已关闭")
|
||||
}
|
||||
132
cmd/ops-runner/main.go
Normal file
132
cmd/ops-runner/main.go
Normal file
@@ -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 <db_path> <base_dir> <command_text>")
|
||||
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 <name> <content> [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 <record_id|name> on|off")
|
||||
os.Exit(2)
|
||||
}
|
||||
mode := strings.ToLower(parts[3])
|
||||
if mode != "on" && mode != "off" {
|
||||
fmt.Println("ERR: /cf dnsproxy <record_id|name> 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)
|
||||
}
|
||||
}
|
||||
66
cmd/runbook_test.go
Normal file
66
cmd/runbook_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
//go:build runbooktest
|
||||
// +build runbooktest
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"ops-assistant/internal/core/ops"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("usage: runbook_test <db_path> <base_dir> <command_text>")
|
||||
os.Exit(2)
|
||||
}
|
||||
dbPath := os.Args[1]
|
||||
baseDir := os.Args[2]
|
||||
cmd := os.Args[3]
|
||||
inputs := map[string]string{}
|
||||
// minimal parse for /cf dnsadd <name> <content> [true] [type]
|
||||
parts := split(cmd)
|
||||
if len(parts) >= 4 && parts[0] == "/cf" && parts[1] == "dnsadd" {
|
||||
inputs["name"] = parts[2]
|
||||
inputs["content"] = parts[3]
|
||||
inputs["type"] = "A"
|
||||
inputs["proxied"] = "false"
|
||||
if len(parts) >= 5 {
|
||||
if parts[4] == "true" {
|
||||
inputs["proxied"] = "true"
|
||||
if len(parts) >= 6 {
|
||||
inputs["type"] = parts[5]
|
||||
}
|
||||
} else {
|
||||
inputs["type"] = parts[4]
|
||||
}
|
||||
}
|
||||
}
|
||||
jobID, _, err := ops.RunOnce(dbPath, filepath.Clean(baseDir), cmd, "cf_dns_add", 1, inputs)
|
||||
if err != nil {
|
||||
fmt.Printf("ERR: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("OK job=%d\n", jobID)
|
||||
}
|
||||
|
||||
func split(s string) []string {
|
||||
out := []string{}
|
||||
cur := ""
|
||||
for _, r := range s {
|
||||
if r == ' ' || r == '\t' || r == '\n' {
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
cur = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
cur += string(r)
|
||||
}
|
||||
if cur != "" {
|
||||
out = append(out, cur)
|
||||
}
|
||||
return out
|
||||
}
|
||||
26
config.yaml.example
Normal file
26
config.yaml.example
Normal file
@@ -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"
|
||||
121
config/config.go
Normal file
121
config/config.go
Normal file
@@ -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
|
||||
}
|
||||
BIN
dist/ops-assistant-v0.0.1-linux-amd64
vendored
Executable file
BIN
dist/ops-assistant-v0.0.1-linux-amd64
vendored
Executable file
Binary file not shown.
1
dist/ops-assistant-v0.0.1-linux-amd64.sha256
vendored
Normal file
1
dist/ops-assistant-v0.0.1-linux-amd64.sha256
vendored
Normal file
@@ -0,0 +1 @@
|
||||
55bfe12944a42957532b9f63492d9ed8ca600419c4352ffa35344a62598bc019 dist/ops-assistant-v0.0.1-linux-amd64
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -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
|
||||
36
docs/API_RESPONSE_CONVENTION.md
Normal file
36
docs/API_RESPONSE_CONVENTION.md
Normal file
@@ -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
|
||||
150
docs/ai_command_guide.md
Normal file
150
docs/ai_command_guide.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# AI 命令解释知识库(用于自然语言→标准命令翻译)
|
||||
|
||||
## 总原则
|
||||
- 目标:把用户的自然语言翻译成**唯一**的标准命令。
|
||||
- 只输出一行命令,不要解释。
|
||||
- 不能确定时输出 `FAIL`。
|
||||
- 标准命令必须以 `/` 开头。
|
||||
|
||||
---
|
||||
|
||||
## OPS 模块
|
||||
### /help
|
||||
**用途**:显示 OPS 可用命令清单(同 /start /ops help)。
|
||||
**常见自然语**:
|
||||
- 你能做什么 / 你会什么
|
||||
- 功能 / 菜单 / 帮助 / help
|
||||
- 查看命令 / 看下命令
|
||||
**输出命令**:
|
||||
- `/help`
|
||||
|
||||
### /ops modules
|
||||
**用途**:查看模块启用状态。
|
||||
**常见自然语**:
|
||||
- 模块状态 / 查看模块
|
||||
- ops 模块 / ops modules
|
||||
**输出命令**:
|
||||
- `/ops modules`
|
||||
|
||||
---
|
||||
|
||||
## CPA 模块
|
||||
### /cpa help
|
||||
**用途**:显示 CPA 模块可用命令。
|
||||
**常见自然语**:
|
||||
- cpa 帮助 / cpa help
|
||||
**输出命令**:
|
||||
- `/cpa help`
|
||||
|
||||
**用途**:查询 CPA 服务状态与 usage 快照。
|
||||
**常见自然语**:
|
||||
- cpa状态 / CPA状态 / cpa status / cpastatus
|
||||
- cpa 服务状态 / cpa 运行状态 / cpa 正常吗
|
||||
- 查下 cpa / 看下 cpa 状态
|
||||
**输出命令**:
|
||||
- `/cpa status`
|
||||
|
||||
### /cpa usage backup
|
||||
**用途**:导出实时 usage 并打包备份。
|
||||
**常见自然语**:
|
||||
- cpa 备份 / 备份 cpa
|
||||
- 备份 usage / cpa usage 备份
|
||||
- 导出 cpa 使用情况
|
||||
**输出命令**:
|
||||
- `/cpa usage backup`
|
||||
|
||||
### /cpa usage restore <backup_id>
|
||||
**用途**:从备份恢复 usage。
|
||||
**常见自然语**:
|
||||
- 恢复 cpa / 回滚 cpa
|
||||
- 用备份 <id> 恢复
|
||||
- 恢复 usage <id>
|
||||
**输出命令**:
|
||||
- `/cpa usage restore <backup_id>`
|
||||
|
||||
---
|
||||
|
||||
## Cloudflare 模块
|
||||
### /cf status
|
||||
**用途**:查看 Cloudflare 配置状态。
|
||||
**常见自然语**:
|
||||
- cf 状态 / cloudflare 状态
|
||||
- cf 配置怎么样
|
||||
**输出命令**:
|
||||
- `/cf status`
|
||||
|
||||
### /cf zones
|
||||
**用途**:列出 zone 列表。
|
||||
**常见自然语**:
|
||||
- cf zones / cf 区域列表 / 站点列表
|
||||
**输出命令**:
|
||||
- `/cf zones`
|
||||
|
||||
### /cf dns list <zone_id>
|
||||
**用途**:列出指定 zone 的 DNS 记录。
|
||||
**常见自然语**:
|
||||
- 列出 DNS / 查看 DNS / cf dns list
|
||||
- zone <id> 的 dns 记录
|
||||
**输出命令**:
|
||||
- `/cf dns list <zone_id>`
|
||||
|
||||
### /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]
|
||||
**用途**:更新 DNS 记录。
|
||||
**常见自然语**:
|
||||
- 更新 dns / 修改解析
|
||||
- 把 <name> 改成 <ip>
|
||||
**输出命令**:
|
||||
- `/cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]`
|
||||
|
||||
### /cf dnsadd <name> <content> [true] [type]
|
||||
**用途**:新增 DNS 记录(name 自动匹配 zone)。
|
||||
**常见自然语**:
|
||||
- 新增解析 / 添加 DNS
|
||||
- 增加 <name> -> <ip>
|
||||
**输出命令**:
|
||||
- `/cf dnsadd <name> <content> [true] [type]`
|
||||
|
||||
### /cf dnsset <record_id> <content> [true]
|
||||
**用途**:按 record_id 修改内容(可选开启代理)。
|
||||
**常见自然语**:
|
||||
- 更新记录内容 / 改 IP
|
||||
**输出命令**:
|
||||
- `/cf dnsset <record_id> <content> [true]`
|
||||
|
||||
### /cf dnsdel <record_id> YES
|
||||
**用途**:删除 DNS 记录(需 YES 确认)。
|
||||
**常见自然语**:
|
||||
- 删除 DNS 记录 / 删解析
|
||||
**输出命令**:
|
||||
- `/cf dnsdel <record_id> YES`
|
||||
|
||||
### /cf dnsproxy <record_id> on|off
|
||||
**用途**:切换代理开关(不改 content)。
|
||||
**常见自然语**:
|
||||
- 开启/关闭代理
|
||||
**输出命令**:
|
||||
- `/cf dnsproxy <record_id> on|off`
|
||||
|
||||
### /cf workers list
|
||||
**用途**:列出 Workers。
|
||||
**常见自然语**:
|
||||
- 列出 workers / cf workers
|
||||
**输出命令**:
|
||||
- `/cf workers list`
|
||||
|
||||
---
|
||||
|
||||
## Mail 模块
|
||||
### /mail status
|
||||
**用途**:查看邮件服务/配置状态。
|
||||
**常见自然语**:
|
||||
- 邮件状态 / mail 状态
|
||||
- 邮箱配置是否正常
|
||||
**输出命令**:
|
||||
- `/mail status`
|
||||
|
||||
---
|
||||
|
||||
## 兜底规则
|
||||
- 无法确定命令或缺少关键参数时输出 `FAIL`。
|
||||
- 不要猜测 zone_id/record_id/backup_id 等关键参数。
|
||||
104
docs/architecture-core.md
Normal file
104
docs/architecture-core.md
Normal file
@@ -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 管理,不改代码路径
|
||||
89
docs/backend-independence-status.md
Normal file
89
docs/backend-independence-status.md
Normal file
@@ -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` 并行。
|
||||
123
docs/cf-commands-design.md
Normal file
123
docs/cf-commands-design.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# CF 命令设计(ops-assistant v1)
|
||||
|
||||
## 目标
|
||||
为 ops-assistant 提供 Cloudflare DNS 管理能力,保证幂等、安全、可审计。
|
||||
|
||||
---
|
||||
|
||||
## 1. 命令清单
|
||||
|
||||
- `/cf zones`
|
||||
- 列出可用 Zone(name/id)
|
||||
|
||||
- `/cf dnslist <zone> [name] [type]`
|
||||
- 示例:`/cf dnslist example.com`
|
||||
- 示例:`/cf dnslist example.com www A`
|
||||
|
||||
- `/cf dnsadd <fqdn> <ip> [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]`
|
||||
- 示例:`/cf dnsadd test.fnos.xx.kg 1.2.3.4`
|
||||
|
||||
- `/cf dnsset <fqdn> <ip> [--type A/AAAA/CNAME] [--ttl 120] [--proxied on/off]`
|
||||
- 存在则更新,不存在则创建
|
||||
|
||||
- `/cf dnsdel <fqdn> --type <A/AAAA/CNAME>`
|
||||
- **必须带 `--type`**
|
||||
- **需二次确认 `YES`**
|
||||
|
||||
- `/cf dnsproxy <fqdn> on|off`
|
||||
- 仅更新 proxied
|
||||
|
||||
- `/cf health`
|
||||
- Token/权限可用性检查
|
||||
|
||||
---
|
||||
|
||||
## 2. 权限与安全
|
||||
|
||||
- `read` 角色:仅允许 `zones / dnslist / health`
|
||||
- `admin` 角色:允许所有命令
|
||||
- `dnsdel` 必须二次确认 `YES`
|
||||
|
||||
---
|
||||
|
||||
## 3. 参数校验
|
||||
|
||||
- `fqdn` 必须属于某个 Zone(`<sub>.<zone>`)
|
||||
- `ip` 校验:
|
||||
- `A` → IPv4
|
||||
- `AAAA` → IPv6
|
||||
- `type` 默认 `A`
|
||||
- `ttl` 默认 `120`
|
||||
- `proxied` 默认保持原值(仅 dnsproxy/dnsset 明确修改)
|
||||
|
||||
---
|
||||
|
||||
## 4. 幂等与更新规则
|
||||
|
||||
### `/cf dnsadd`
|
||||
1) 查询同名同类型记录
|
||||
2) 存在 → 返回“已存在”
|
||||
3) 不存在 → 创建
|
||||
|
||||
### `/cf dnsset`
|
||||
- 若任一字段不同则更新:
|
||||
- `content`
|
||||
- `ttl`
|
||||
- `proxied`
|
||||
- content 相同但 ttl/proxied 不同 → 仍执行更新
|
||||
|
||||
### `/cf dnsdel`
|
||||
- 必须带 `--type`
|
||||
- 仅删除指定 type 记录
|
||||
|
||||
---
|
||||
|
||||
## 5. 输出规范
|
||||
|
||||
### 成功
|
||||
```
|
||||
✅ DNS操作成功
|
||||
- name: <fqdn>
|
||||
- type: <type>
|
||||
- content: <ip>
|
||||
- ttl: <ttl>
|
||||
- proxied: <true/false>
|
||||
- id: <record_id>
|
||||
```
|
||||
|
||||
### 已存在
|
||||
```
|
||||
ℹ️ DNS记录已存在(未变更)
|
||||
- name: <fqdn>
|
||||
- type: <type>
|
||||
- content: <ip>
|
||||
- id: <record_id>
|
||||
```
|
||||
|
||||
### 失败
|
||||
```
|
||||
❌ DNS操作失败
|
||||
- reason: <鉴权/参数/CF错误>
|
||||
- detail: <error_message>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 审计日志(必写)
|
||||
|
||||
字段:
|
||||
- `ts / actor / cmd / zone / fqdn / action / record_id / result / detail`
|
||||
|
||||
示例:
|
||||
```json
|
||||
{
|
||||
"ts": "2026-03-14T15:02:10+08:00",
|
||||
"actor": "kory",
|
||||
"cmd": "/cf dnsadd test.fnos.xx.kg 1.2.3.4",
|
||||
"zone": "fnos.xx.kg",
|
||||
"record_id": "xxxxx",
|
||||
"action": "create",
|
||||
"result": "ok",
|
||||
"detail": {"type":"A","ttl":120,"proxied":false}
|
||||
}
|
||||
```
|
||||
39
docs/cf-dns-runbook-incident-20260313.md
Normal file
39
docs/cf-dns-runbook-incident-20260313.md
Normal file
@@ -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 远端执行。
|
||||
28
docs/channel-adapter-template.md
Normal file
28
docs/channel-adapter-template.md
Normal file
@@ -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
|
||||
- [ ] 错误信息对用户可读
|
||||
79
docs/debug/cf-dnsproxy-dnsadd-20260319.md
Normal file
79
docs/debug/cf-dnsproxy-dnsadd-20260319.md
Normal file
@@ -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 建议统一使用单引号包裹
|
||||
491
docs/frontend-api-handoff.md
Normal file
491
docs/frontend-api-handoff.md
Normal file
@@ -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 可忽略(非功能错误)。
|
||||
98
docs/frontend-joint-checklist.md
Normal file
98
docs/frontend-joint-checklist.md
Normal file
@@ -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 下线窗口
|
||||
37
docs/module-adapter-template.md
Normal file
37
docs/module-adapter-template.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Module Adapter Template
|
||||
|
||||
目标:新功能模块以最小改动接入 `ops-assistant`。
|
||||
|
||||
## 必备文件
|
||||
|
||||
- `internal/module/<name>/module.go`
|
||||
- `runbooks/<name>_*.yaml`
|
||||
|
||||
## 推荐实现
|
||||
|
||||
1. 在 `module.go` 内解析命令前缀(如 `/cf`)
|
||||
2. 构建 `core/module.Request`
|
||||
3. 统一调用 `core/module.Runner.Run(...)`
|
||||
4. 所有 gate(flag/confirm/dry-run)通过 `Request.Gate` 声明
|
||||
|
||||
## 最小示例(伪代码)
|
||||
|
||||
```go
|
||||
req := coremodule.Request{
|
||||
RunbookName: "cf_dns_upsert",
|
||||
Inputs: map[string]string{"zone": zone, "name": name},
|
||||
Meta: runbook.RunMeta{Target:"cf", RiskLevel:"medium"},
|
||||
Gate: coremodule.Gate{NeedFlag:"allow_cf_write", RequireConfirm:true, ExpectedToken:"YES_CF", AllowDryRun:true},
|
||||
DryRun: dryRun,
|
||||
ConfirmToken: confirm,
|
||||
}
|
||||
jobID, out, err := runner.Run(cmd.Raw, userID, req)
|
||||
```
|
||||
|
||||
## 接入检查
|
||||
|
||||
- [ ] 命令不直接执行 shell/ssh
|
||||
- [ ] 使用统一 Runner
|
||||
- [ ] 风险操作有 Gate
|
||||
- [ ] 返回 job_id 可追踪
|
||||
- [ ] runbook step 可审计
|
||||
72
docs/multi-platform-channel-deploy.md
Normal file
72
docs/multi-platform-channel-deploy.md
Normal file
@@ -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:<update_id>`
|
||||
- qqbot_official: `qq:<type>:<message_id>`
|
||||
- feishu: `event_id`(回退 message_id)
|
||||
|
||||
## 运行建议
|
||||
- 对公网暴露前请加 HTTPS(飞书回调必需)
|
||||
- 建议将管理后台放在内网或反代鉴权后访问
|
||||
- 定期审计 `audit_logs` 里渠道配置修改记录
|
||||
200
docs/ops-assistant-v1.md
Normal file
200
docs/ops-assistant-v1.md
Normal file
@@ -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 <backup_id>`
|
||||
- `/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.<id>.output.*`(前置步骤输出)
|
||||
|
||||
---
|
||||
|
||||
## 审计与可追溯
|
||||
|
||||
建议新增两张表:
|
||||
|
||||
### 1) `ops_jobs`
|
||||
- `id`
|
||||
- `command`
|
||||
- `runbook`
|
||||
- `operator_id`
|
||||
- `status`(pending/running/success/failed)
|
||||
- `started_at`
|
||||
- `ended_at`
|
||||
|
||||
### 2) `ops_job_steps`
|
||||
- `id`
|
||||
- `job_id`
|
||||
- `step_id`
|
||||
- `action`
|
||||
- `status`
|
||||
- `rc`
|
||||
- `stdout_tail`
|
||||
- `stderr_tail`
|
||||
- `started_at`
|
||||
- `ended_at`
|
||||
|
||||
> 所有 Bot 命令回执都返回 `job_id`,便于追踪。
|
||||
|
||||
---
|
||||
|
||||
## 风险控制
|
||||
|
||||
- 默认 `dry-run=true`(先演练)
|
||||
- 高风险步骤(restore/import)必须:
|
||||
- 双确认(命令 + confirm token)
|
||||
- feature flag 开启才允许执行
|
||||
- 审计日志不写明文 secrets
|
||||
- 全部 secrets 走现有 `channel` 加密机制存储
|
||||
|
||||
---
|
||||
|
||||
## 与现有系统的集成点(独立项目并存)
|
||||
|
||||
1. **Bot 层**:在 Telegram/QQ/飞书消息处理中增加 `/cpa` 命令分流
|
||||
2. **Web 层**:新增 `/ops` 页面查看任务状态与步骤日志
|
||||
3. **模型层**:新增 `ops_jobs` / `ops_job_steps`
|
||||
4. **配置层**:增加 `ops.targets`(如 hwsg/wjynl)
|
||||
|
||||
---
|
||||
|
||||
## 阶段性进度汇报原则(必须遵守)
|
||||
|
||||
**模板(四要素)**
|
||||
1) 阶段名(设计/实现/自测/上线等)
|
||||
2) 已执行动作(具体到做了什么)
|
||||
3) 可验证证据(日志、产物路径、返回码、截图等)
|
||||
4) 下一步与前置条件(还差什么、需谁确认)
|
||||
|
||||
**硬规则**
|
||||
- 无证据不使用“完成/已执行/进行中”等措辞
|
||||
- 遇到卡点必须立即说明:卡点 + 原因 + 需要的唯一输入
|
||||
- 先清单后执行的任务,未确认不得执行
|
||||
|
||||
---
|
||||
|
||||
## 事故复盘(2026-03-13,CF DNS Runbook)
|
||||
|
||||
详见:`docs/cf-dns-runbook-incident-20260313.md`
|
||||
|
||||
要点摘要:
|
||||
- 认证方式误判(API Token 被当作 API Key)。
|
||||
- heredoc/转义在 ssh.exec 中导致脚本未执行。
|
||||
- 最终采用 base64 + python3 -c exec 稳定执行。
|
||||
- 强化“阶段性汇报必须带证据”的纪律。
|
||||
|
||||
---
|
||||
|
||||
## 验收标准(v1)
|
||||
|
||||
1. `/cpa status` 能返回结构化结果(非自由文本)
|
||||
2. `/cpa usage backup` 能输出:备份路径 + sha256 + usage 快照
|
||||
3. `/cpa usage restore <id>` 支持双校验(立即 + 延迟)
|
||||
4. 任一步骤失败时可追溯到具体 step 日志
|
||||
5. 未授权命令必须被拒绝并记录审计
|
||||
|
||||
---
|
||||
|
||||
## 当前已落地(2026-03-10)
|
||||
|
||||
1. 已打通命令入口:Telegram / QQ / 飞书
|
||||
2. 已支持命令:
|
||||
- `/cpa status`
|
||||
- `/cpa usage backup`
|
||||
- `/cpa usage restore <backup_id>`
|
||||
3. 已提供查询接口:
|
||||
- `GET /api/v1/ops/jobs`
|
||||
- `GET /api/v1/ops/jobs/:id`(含 steps)
|
||||
4. `assert.json` 已支持真实 JSON 路径断言
|
||||
5. 已增加安全闸门:
|
||||
- `allow_ops_restore` feature flag(默认 false)
|
||||
- restore 需要 `--confirm YES_RESTORE`
|
||||
- 支持 `--dry-run`
|
||||
6. 已支持 `ops_targets` 目标主机表(优先解析 target 名称)
|
||||
|
||||
## 当前 Core 强化(2026-03-10 第二阶段)
|
||||
|
||||
1. 同 target 串行锁(避免并发覆盖)
|
||||
2. 作业元信息增强:`target/risk_level/request_id/confirm_hash`
|
||||
3. 统一错误码前缀(如 `ERR_FEATURE_DISABLED` / `ERR_CONFIRM_REQUIRED` / `ERR_STEP_FAILED`)
|
||||
4. step 超时控制(默认 45s)
|
||||
5. 任务取消接口:`POST /api/v1/ops/jobs/:id/cancel`
|
||||
|
||||
## 下一步落地建议
|
||||
|
||||
1. 为取消操作增加权限细分(`ops.cancel`)
|
||||
2. 增加 job 重试接口(仅失败任务可重试)
|
||||
3. 增加步骤级超时配置(runbook 可覆盖)
|
||||
4. 增加 Cloudflare / Mail 模块(在 Core 验收完成后)
|
||||
52
go.mod
Normal file
52
go.mod
Normal file
@@ -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
|
||||
)
|
||||
272
go.sum
Normal file
272
go.sum
Normal file
@@ -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=
|
||||
237
internal/bot/telegram.go
Normal file
237
internal/bot/telegram.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
442
internal/channel/channel.go
Normal file
442
internal/channel/channel.go
Normal file
@@ -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
|
||||
}
|
||||
126
internal/chart/chart.go
Normal file
126
internal/chart/chart.go
Normal file
@@ -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
|
||||
}
|
||||
24
internal/core/ai/advisor.go
Normal file
24
internal/core/ai/advisor.go
Normal file
@@ -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
|
||||
}
|
||||
103
internal/core/ai/client.go
Normal file
103
internal/core/ai/client.go
Normal file
@@ -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
|
||||
}
|
||||
40
internal/core/ai/loader.go
Normal file
40
internal/core/ai/loader.go
Normal file
@@ -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}
|
||||
}
|
||||
67
internal/core/command/parser.go
Normal file
67
internal/core/command/parser.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ParsedCommand struct {
|
||||
Raw string
|
||||
Name string
|
||||
Args []string
|
||||
Module string
|
||||
}
|
||||
|
||||
func Parse(raw string) (*ParsedCommand, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" || !strings.HasPrefix(text, "/") {
|
||||
return nil, fmt.Errorf("not a command")
|
||||
}
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("empty command")
|
||||
}
|
||||
|
||||
cmd := &ParsedCommand{Raw: text, Name: parts[0]}
|
||||
if len(parts) > 1 {
|
||||
cmd.Args = parts[1:]
|
||||
}
|
||||
mod := strings.TrimPrefix(cmd.Name, "/")
|
||||
if i := strings.Index(mod, "@"); i > 0 {
|
||||
mod = mod[:i]
|
||||
}
|
||||
if mod != "" {
|
||||
cmd.Module = mod
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// ParseWithInputs: 支持 /cf dns list <zone_id> 这种输入参数写入 runbook inputs
|
||||
func ParseWithInputs(raw string) (*ParsedCommand, map[string]string, error) {
|
||||
cmd, err := Parse(raw)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
inputs := map[string]string{}
|
||||
if cmd.Module == "cf" {
|
||||
// /cf dns list <zone_id>
|
||||
if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "list" && len(cmd.Args) >= 3 {
|
||||
inputs["zone_id"] = cmd.Args[2]
|
||||
}
|
||||
// /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]
|
||||
if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "update" && len(cmd.Args) >= 7 {
|
||||
inputs["zone_id"] = cmd.Args[2]
|
||||
inputs["record_id"] = cmd.Args[3]
|
||||
inputs["type"] = cmd.Args[4]
|
||||
inputs["name"] = cmd.Args[5]
|
||||
inputs["content"] = cmd.Args[6]
|
||||
if len(cmd.Args) >= 8 {
|
||||
inputs["ttl"] = cmd.Args[7]
|
||||
}
|
||||
if len(cmd.Args) >= 9 {
|
||||
inputs["proxied"] = cmd.Args[8]
|
||||
}
|
||||
}
|
||||
}
|
||||
return cmd, inputs, nil
|
||||
}
|
||||
17
internal/core/ecode/codes.go
Normal file
17
internal/core/ecode/codes.go
Normal file
@@ -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
|
||||
}
|
||||
19
internal/core/module/request.go
Normal file
19
internal/core/module/request.go
Normal file
@@ -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
|
||||
}
|
||||
48
internal/core/module/runner.go
Normal file
48
internal/core/module/runner.go
Normal file
@@ -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)
|
||||
}
|
||||
26
internal/core/module/switches.go
Normal file
26
internal/core/module/switches.go
Normal file
@@ -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)
|
||||
}
|
||||
97
internal/core/module/template.go
Normal file
97
internal/core/module/template.go
Normal file
@@ -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
|
||||
}
|
||||
26
internal/core/ops/bootstrap.go
Normal file
26
internal/core/ops/bootstrap.go
Normal file
@@ -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)
|
||||
}
|
||||
60
internal/core/ops/retry.go
Normal file
60
internal/core/ops/retry.go
Normal file
@@ -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)
|
||||
}
|
||||
20
internal/core/ops/run_once.go
Normal file
20
internal/core/ops/run_once.go
Normal file
@@ -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())
|
||||
}
|
||||
100
internal/core/ops/service.go
Normal file
100
internal/core/ops/service.go
Normal file
@@ -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 <backup_id> [--confirm YES_RESTORE] [--dry-run]",
|
||||
"- /cf status (需要 enable_module_cf)",
|
||||
"- /cf zones (需要 enable_module_cf)",
|
||||
"- /cf dns list <zone_id> (需要 enable_module_cf)",
|
||||
"- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false] (需要 enable_module_cf)",
|
||||
"- /cf dnsadd <name> <content> [on|off] [type] (需要 enable_module_cf)",
|
||||
"- /cf dnsset <record_id> <content> [true] (需要 enable_module_cf)",
|
||||
"- /cf dnsdel <record_id> YES (需要 enable_module_cf)",
|
||||
"- /cf dnsproxy <record_id|name> on|off (需要 enable_module_cf)",
|
||||
"- /cf workers list (需要 enable_module_cf)",
|
||||
"- /mail status (需要 enable_module_mail)",
|
||||
}
|
||||
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")
|
||||
}
|
||||
62
internal/core/policy/policy.go
Normal file
62
internal/core/policy/policy.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ops-assistant/models"
|
||||
)
|
||||
|
||||
type GateRequest struct {
|
||||
NeedFlag string
|
||||
RequireConfirm bool
|
||||
ConfirmToken string
|
||||
ExpectedToken string
|
||||
AllowDryRun bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func ParseCommonFlags(text string) (dryRun bool, confirmToken string) {
|
||||
parts := strings.Fields(strings.TrimSpace(text))
|
||||
for i := 0; i < len(parts); i++ {
|
||||
if parts[i] == "--dry-run" {
|
||||
dryRun = true
|
||||
}
|
||||
if parts[i] == "--confirm" && i+1 < len(parts) {
|
||||
confirmToken = strings.TrimSpace(parts[i+1])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func FlagEnabled(db *gorm.DB, key string) bool {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return true
|
||||
}
|
||||
var ff models.FeatureFlag
|
||||
if err := db.Where("key = ?", key).First(&ff).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return ff.Enabled
|
||||
}
|
||||
|
||||
func CheckGate(db *gorm.DB, req GateRequest) error {
|
||||
if strings.TrimSpace(req.NeedFlag) != "" && !FlagEnabled(db, req.NeedFlag) {
|
||||
return errors.New("feature flag 未启用: " + req.NeedFlag)
|
||||
}
|
||||
if req.RequireConfirm {
|
||||
if strings.TrimSpace(req.ConfirmToken) == "" {
|
||||
return errors.New("缺少 --confirm <token>")
|
||||
}
|
||||
if strings.TrimSpace(req.ExpectedToken) != "" && strings.TrimSpace(req.ConfirmToken) != strings.TrimSpace(req.ExpectedToken) {
|
||||
return errors.New("确认 token 无效")
|
||||
}
|
||||
}
|
||||
if req.DryRun && !req.AllowDryRun {
|
||||
return errors.New("当前命令不允许 dry-run")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
14
internal/core/ports/channel.go
Normal file
14
internal/core/ports/channel.go
Normal file
@@ -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
|
||||
}
|
||||
6
internal/core/ports/module.go
Normal file
6
internal/core/ports/module.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package ports
|
||||
|
||||
type Module interface {
|
||||
Name() string
|
||||
CommandPrefix() string
|
||||
}
|
||||
47
internal/core/registry/registry.go
Normal file
47
internal/core/registry/registry.go
Normal file
@@ -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
|
||||
}
|
||||
32
internal/core/runbook/cancel.go
Normal file
32
internal/core/runbook/cancel.go
Normal file
@@ -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
|
||||
}
|
||||
387
internal/core/runbook/executor.go
Normal file
387
internal/core/runbook/executor.go
Normal file
@@ -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:]
|
||||
}
|
||||
21
internal/core/runbook/lock.go
Normal file
21
internal/core/runbook/lock.go
Normal file
@@ -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() }
|
||||
}
|
||||
23
internal/core/runbook/meta.go
Normal file
23
internal/core/runbook/meta.go
Normal file
@@ -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
|
||||
}
|
||||
20
internal/core/runbook/seed_targets.go
Normal file
20
internal/core/runbook/seed_targets.go
Normal file
@@ -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
|
||||
}
|
||||
37
internal/core/runbook/targets.go
Normal file
37
internal/core/runbook/targets.go
Normal file
@@ -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}
|
||||
}
|
||||
24
internal/core/runbook/types.go
Normal file
24
internal/core/runbook/types.go
Normal file
@@ -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
|
||||
}
|
||||
138
internal/feishu/feishu.go
Normal file
138
internal/feishu/feishu.go
Normal file
@@ -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:]
|
||||
}
|
||||
254
internal/module/cf/commands.go
Normal file
254
internal/module/cf/commands.go
Normal file
@@ -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 <zone_id>"))
|
||||
}
|
||||
return map[string]string{"zone_id": parts[3]}, nil
|
||||
},
|
||||
Gate: coremodule.Gate{
|
||||
NeedFlag: "enable_module_cf",
|
||||
AllowDryRun: true,
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dns list <zone_id>(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cf dns list 执行失败: ",
|
||||
ErrHint: "/cf dns list <zone_id>",
|
||||
},
|
||||
{
|
||||
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 <zone_id> <record_id> <type> <name> <content> [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 <zone_id> <record_id> <type> <name> <content> [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 <name> <content> [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 <name> <content> [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 <record_id> <content> [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 <record_id> <content> [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 <record_id> YES"))
|
||||
}
|
||||
if len(parts) < 4 || !strings.EqualFold(parts[3], "YES") {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "缺少确认词 YES,示例:/cf dnsdel <record_id> 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 <record_id> 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 <record_id|name> on|off"))
|
||||
}
|
||||
mode := strings.ToLower(parts[3])
|
||||
if mode != "on" && mode != "off" {
|
||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数无效,示例:/cf dnsproxy <record_id|name> 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 <record_id|name> 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 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
40
internal/module/cf/module.go
Normal file
40
internal/module/cf/module.go
Normal file
@@ -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 <zone_id>\n- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false]\n- /cf dnsadd <name> <content> [on|off] [type]\n- /cf dnsset <record_id> <content> [true]\n- /cf dnsdel <record_id> YES\n- /cf dnsproxy <record_id|name> 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
|
||||
}
|
||||
62
internal/module/cpa/commands.go
Normal file
62
internal/module/cpa/commands.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package cpa
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
)
|
||||
|
||||
func commandSpecs() []coremodule.CommandSpec {
|
||||
return []coremodule.CommandSpec{
|
||||
{
|
||||
Prefixes: []string{"/cpa status"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cpa_status",
|
||||
Gate: coremodule.Gate{AllowDryRun: true},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cpa status(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa status 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cpa status 执行失败: ",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cpa usage backup"},
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cpa_usage_backup",
|
||||
Gate: coremodule.Gate{AllowDryRun: true},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 /cpa usage backup(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage backup 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cpa usage backup 执行失败: ",
|
||||
},
|
||||
{
|
||||
Prefixes: []string{"/cpa usage restore "},
|
||||
ErrHint: "--confirm YES_RESTORE",
|
||||
Template: coremodule.CommandTemplate{
|
||||
RunbookName: "cpa_usage_restore",
|
||||
Gate: coremodule.Gate{NeedFlag: "allow_ops_restore", RequireConfirm: true, ExpectedToken: "YES_RESTORE", AllowDryRun: true},
|
||||
InputsFn: func(_ string, parts []string) (map[string]string, error) {
|
||||
if len(parts) < 4 {
|
||||
return nil, fmt.Errorf("❌ 用法:/cpa usage restore <backup_id>")
|
||||
}
|
||||
backupID := strings.TrimSpace(parts[3])
|
||||
if backupID == "" {
|
||||
return nil, fmt.Errorf("❌ backup_id 不能为空")
|
||||
}
|
||||
return map[string]string{"backup_id": backupID}, nil
|
||||
},
|
||||
MetaFn: func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta {
|
||||
meta := runbook.NewMeta()
|
||||
meta.Target = "hwsg"
|
||||
meta.RiskLevel = "high"
|
||||
meta.ConfirmHash = hashConfirmToken(confirmToken)
|
||||
return meta
|
||||
},
|
||||
DryRunMsg: "🧪 dry-run: 将执行 restore(未实际执行)",
|
||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cpa usage restore 已执行,job=%d", jobID) },
|
||||
},
|
||||
ErrPrefix: "/cpa usage restore 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
16
internal/module/cpa/crypto.go
Normal file
16
internal/module/cpa/crypto.go
Normal file
@@ -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[:])
|
||||
}
|
||||
13
internal/module/cpa/guards.go
Normal file
13
internal/module/cpa/guards.go
Normal file
@@ -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)
|
||||
}
|
||||
40
internal/module/cpa/module.go
Normal file
40
internal/module/cpa/module.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package cpa
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"ops-assistant/internal/core/command"
|
||||
"ops-assistant/internal/core/ecode"
|
||||
coremodule "ops-assistant/internal/core/module"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Module struct {
|
||||
db *gorm.DB
|
||||
exec *runbook.Executor
|
||||
runner *coremodule.Runner
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, exec *runbook.Executor) *Module {
|
||||
return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)}
|
||||
}
|
||||
|
||||
func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) {
|
||||
text := strings.TrimSpace(cmd.Raw)
|
||||
if text == "/cpa" || strings.HasPrefix(text, "/cpa help") {
|
||||
return "CPA 模块\n- /cpa status\n- /cpa usage backup\n- /cpa usage restore <backup_id> [--confirm YES_RESTORE] [--dry-run]", nil
|
||||
}
|
||||
specs := commandSpecs()
|
||||
if sp, ok := coremodule.MatchCommand(text, specs); ok {
|
||||
jobID, out, err := coremodule.ExecTemplate(m.runner, userID, cmd.Raw, sp.Template)
|
||||
if err != nil {
|
||||
return formatErr(ecode.ErrStepFailed, coremodule.FormatExecError(sp, err)), nil
|
||||
}
|
||||
if out == "dry-run" {
|
||||
return formatOK(coremodule.FormatDryRunMessage(sp.Template)), nil
|
||||
}
|
||||
return formatOK(coremodule.FormatSuccessMessage(sp.Template, jobID)), nil
|
||||
}
|
||||
return "❓ 暂不支持该 CPA 命令。当前支持:/cpa status, /cpa usage backup, /cpa usage restore <backup_id>", nil
|
||||
}
|
||||
25
internal/module/mail/commands.go
Normal file
25
internal/module/mail/commands.go
Normal file
@@ -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 执行失败: ",
|
||||
},
|
||||
}
|
||||
}
|
||||
40
internal/module/mail/module.go
Normal file
40
internal/module/mail/module.go
Normal file
@@ -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
|
||||
}
|
||||
11
internal/qq/ai_loader.go
Normal file
11
internal/qq/ai_loader.go
Normal file
@@ -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)
|
||||
}
|
||||
474
internal/qq/qq.go
Normal file
474
internal/qq/qq.go
Normal file
@@ -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 <zone_id>"
|
||||
case "cfworkers", "cf workers", "workers列表", "workers list", "列workers":
|
||||
cmd := "/cf workers list"
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 非命令:尝试 AI 翻译 -> 标准命令
|
||||
if time.Since(b.aiAutoReload) > 3*time.Second {
|
||||
b.aiClient = buildAIClient(b.db)
|
||||
b.aiAutoReload = time.Now()
|
||||
}
|
||||
if b.aiClient != nil {
|
||||
if cmd, err := b.aiClient.Suggest(text); err == nil {
|
||||
cmd = strings.TrimSpace(cmd)
|
||||
if cmd != "" && cmd != "FAIL" && strings.HasPrefix(cmd, "/") {
|
||||
// 仅翻译成命令,交给模块处理(不回译文)
|
||||
if b.opsSvc != nil {
|
||||
if handled, out := b.opsSvc.Handle(DefaultUserID, cmd); handled {
|
||||
if strings.HasPrefix(cmd, "/cpa ") || cmd == "/cpa" || strings.HasPrefix(cmd, "/cf ") || strings.HasPrefix(cmd, "/mail ") {
|
||||
jobID := parseJobID(out)
|
||||
if jobID > 0 {
|
||||
go b.waitAndPushJobResult(userID, jobID, cmd)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
}
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
} else {
|
||||
if strings.Contains(err.Error(), "rate limited") {
|
||||
return "⚠️ AI 服务繁忙,请稍后再试或使用标准命令"
|
||||
}
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
}
|
||||
}
|
||||
// 仍然无法处理
|
||||
return "❌ 无法识别,请使用标准命令"
|
||||
}
|
||||
|
||||
func parseJobID(out string) uint {
|
||||
re := regexp.MustCompile(`job=(\d+)`)
|
||||
m := re.FindStringSubmatch(out)
|
||||
if len(m) < 2 {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
if n <= 0 {
|
||||
return 0
|
||||
}
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
func (b *QQBot) waitAndPushJobResult(userID string, jobID uint, cmd string) {
|
||||
if b.db == nil {
|
||||
return
|
||||
}
|
||||
var job models.OpsJob
|
||||
for i := 0; i < 15; i++ { // 最多等 ~30s
|
||||
time.Sleep(2 * time.Second)
|
||||
if err := b.db.First(&job, jobID).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
if job.Status == "pending" || job.Status == "running" {
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if job.ID == 0 || job.Status == "pending" || job.Status == "running" {
|
||||
return
|
||||
}
|
||||
msg := formatJobResult(b.db, jobID, cmd)
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
return
|
||||
}
|
||||
_, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg})
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
}
|
||||
154
internal/service/finance.go
Normal file
154
internal/service/finance.go
Normal file
@@ -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
|
||||
}
|
||||
17
internal/web/apiresp.go
Normal file
17
internal/web/apiresp.go
Normal file
@@ -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})
|
||||
}
|
||||
2074
internal/web/server.go
Normal file
2074
internal/web/server.go
Normal file
File diff suppressed because it is too large
Load Diff
336
models/models.go
Normal file
336
models/models.go
Normal file
@@ -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
|
||||
}
|
||||
17
runbooks/cf_dns_add.yaml
Normal file
17
runbooks/cf_dns_add.yaml
Normal file
@@ -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"
|
||||
17
runbooks/cf_dns_del.yaml
Normal file
17
runbooks/cf_dns_del.yaml
Normal file
@@ -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"
|
||||
17
runbooks/cf_dns_list.yaml
Normal file
17
runbooks/cf_dns_list.yaml
Normal file
@@ -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"
|
||||
30
runbooks/cf_dns_proxy.yaml
Normal file
30
runbooks/cf_dns_proxy.yaml
Normal file
@@ -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"
|
||||
30
runbooks/cf_dns_set.yaml
Normal file
30
runbooks/cf_dns_set.yaml
Normal file
@@ -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"
|
||||
17
runbooks/cf_dns_update.yaml
Normal file
17
runbooks/cf_dns_update.yaml
Normal file
@@ -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"
|
||||
10
runbooks/cf_status.yaml
Normal file
10
runbooks/cf_status.yaml
Normal file
@@ -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
|
||||
17
runbooks/cf_workers_list.yaml
Normal file
17
runbooks/cf_workers_list.yaml
Normal file
@@ -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"
|
||||
17
runbooks/cf_zones.yaml
Normal file
17
runbooks/cf_zones.yaml
Normal file
@@ -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"
|
||||
19
runbooks/cpa_status.yaml
Normal file
19
runbooks/cpa_status.yaml
Normal file
@@ -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"
|
||||
38
runbooks/cpa_usage_backup.yaml
Normal file
38
runbooks/cpa_usage_backup.yaml
Normal file
@@ -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}
|
||||
112
runbooks/cpa_usage_restore.yaml
Normal file
112
runbooks/cpa_usage_restore.yaml
Normal file
@@ -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"
|
||||
10
runbooks/mail_status.yaml
Normal file
10
runbooks/mail_status.yaml
Normal file
@@ -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
|
||||
145
templates/ai_settings.html
Normal file
145
templates/ai_settings.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI 配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;
|
||||
--accent:#ee5a24;--accent-hover:#d63031;--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;
|
||||
--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
|
||||
.header button{width:28px;padding:0;transition:transform .2s ease}
|
||||
.header button.spin{transform:rotate(180deg)}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
small{color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🤖 AI 配置 · {{.version}}</div>
|
||||
<div class="right">
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>AI 模型配置</h3>
|
||||
<div class="row"><label>启用 AI 翻译</label><input id="enabled" type="checkbox"></div>
|
||||
<div class="row"><label>Base URL</label><input id="base_url" placeholder="https://api.xxx/v1"></div>
|
||||
<div class="row"><label>API Key</label><input id="api_key" type="password" placeholder="sk-..." autocomplete="new-password"></div>
|
||||
<div class="row"><label>Model</label><input id="model" placeholder="gemini-3-flash-preview"></div>
|
||||
<div class="row"><label>Timeout (秒)</label><input id="timeout" placeholder="15"></div>
|
||||
<small>用于将“非命令文本”翻译为标准命令(仅翻译,不自动执行)。</small>
|
||||
<div class="btns">
|
||||
<button onclick="save()">保存</button>
|
||||
<button class="secondary" onclick="load()">刷新</button>
|
||||
</div>
|
||||
<small id="msg"></small>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/ai/settings');
|
||||
const s=d.settings||{};
|
||||
document.getElementById('enabled').checked = (String(s.ai_enabled||'').toLowerCase()==='true');
|
||||
document.getElementById('base_url').value = s.ai_base_url || '';
|
||||
document.getElementById('api_key').value = s.ai_api_key || '';
|
||||
document.getElementById('model').value = s.ai_model || '';
|
||||
document.getElementById('timeout').value = s.ai_timeout_seconds || '15';
|
||||
document.getElementById('msg').textContent='已加载';
|
||||
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(){
|
||||
const payload={
|
||||
enabled: document.getElementById('enabled').checked,
|
||||
base_url: document.getElementById('base_url').value.trim(),
|
||||
api_key: document.getElementById('api_key').value.trim(),
|
||||
model: document.getElementById('model').value.trim(),
|
||||
timeout_seconds: parseInt(document.getElementById('timeout').value||'15',10)
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/ai/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('msg').textContent='已保存';
|
||||
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
|
||||
}
|
||||
function setThemeIcon(theme){
|
||||
const icon=document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
if(theme==='dark'){
|
||||
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
||||
}else{
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle>'+
|
||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
||||
}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
||||
else{document.documentElement.removeAttribute('data-theme');}
|
||||
localStorage.setItem('theme',theme);
|
||||
setThemeIcon(theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
const next = cur==='light'?'dark':'light';
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
||||
applyTheme(next);
|
||||
}
|
||||
(function(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.addEventListener('click',toggleTheme)}
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
211
templates/audit.html
Normal file
211
templates/audit.html
Normal file
@@ -0,0 +1,211 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🧾 审计日志 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .title{font-weight:700}
|
||||
.header .sub{font-size:12px;opacity:.9}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer}
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.header a:hover{background:rgba(255,255,255,.35)}
|
||||
|
||||
.wrap{max-width:760px;margin:0 auto;padding:14px}
|
||||
.filters{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
|
||||
.filters input,.filters select{width:100%;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
small{color:var(--muted)}
|
||||
|
||||
.actions{margin:0 0 10px;display:flex;gap:8px;flex-wrap:wrap}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button:hover{background:var(--accent-hover)}
|
||||
button.secondary{background:#6b7280}
|
||||
button.secondary:hover{background:#4b5563}
|
||||
|
||||
.list{display:flex;flex-direction:column;gap:10px}
|
||||
.log-card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none}
|
||||
.row{display:flex;justify-content:space-between;gap:8px;align-items:flex-start}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:6px;font-size:12px}
|
||||
.tag.success{background:#dcfce7;color:#166534}
|
||||
.tag.failed{background:#fee2e2;color:#991b1b}
|
||||
.tag.denied{background:#fef3c7;color:#92400e}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
|
||||
.note{font-size:13px;color:var(--text);margin-top:6px;white-space:pre-wrap;word-break:break-word}
|
||||
.empty{text-align:center;padding:40px 10px;color:var(--muted)}
|
||||
.hidden{display:none !important;}
|
||||
|
||||
@media(max-width:640px){
|
||||
.header{padding:14px 12px 10px;align-items:flex-start;flex-direction:column}
|
||||
.header>div:last-child{display:flex;gap:8px}
|
||||
.wrap{padding:12px}
|
||||
.filters{grid-template-columns:1fr}
|
||||
}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="title">🧾 审计日志</div>
|
||||
<div class="sub">{{.version}}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a id="btnChannels" href="/channels">渠道配置</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="filters">
|
||||
<div><small>操作类型</small><input id="fAction" placeholder="如:record.delete.self"></div>
|
||||
<div><small>目标类型</small><input id="fTarget" placeholder="如:transaction"></div>
|
||||
<div><small>结果</small><select id="fResult"><option value="">全部</option><option value="success">成功</option><option value="denied">拒绝</option><option value="failed">失败</option></select></div>
|
||||
<div><small>操作人ID</small><input id="fActor" placeholder="如:1"></div>
|
||||
<div><small>开始时间(RFC3339)</small><input id="fFrom" placeholder="2026-03-09T00:00:00+08:00"></div>
|
||||
<div><small>结束时间(RFC3339)</small><input id="fTo" placeholder="2026-03-10T00:00:00+08:00"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button onclick="loadAudit()">查询</button>
|
||||
<button class="secondary" onclick="resetFilters()">重置</button>
|
||||
</div>
|
||||
<div id="list" class="list"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let me=null;
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function qs(id){return document.getElementById(id).value.trim();}
|
||||
async function api(url,opt={}){const r=await fetch(url,opt);const out=await r.json().catch(()=>({}));if(!r.ok) throw new Error(out.message||('HTTP '+r.status));return out?.data||{};}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
|
||||
const actionMap={
|
||||
'auth.login.success':'登录成功','auth.login.failed':'登录失败','auth.logout':'退出登录',
|
||||
'record.delete.self':'删除本人记录','record.delete.all':'删除全员记录','record.export':'导出记录',
|
||||
'flag.update':'修改高级开关',
|
||||
'channel_update_draft':'更新渠道草稿','channel_publish':'发布渠道草稿','channel_reload':'热加载渠道',
|
||||
'channel_disable_all':'一键关闭全部渠道','channel_enable':'启用渠道','channel_disable':'停用渠道','channel_test':'测试渠道连接'
|
||||
};
|
||||
const targetMap={
|
||||
'transaction':'记账记录','feature_flag':'高级开关','channel':'渠道','user':'用户','system':'系统'
|
||||
};
|
||||
|
||||
function actionLabel(v){return actionMap[v]||v||'-';}
|
||||
function targetLabel(v){return targetMap[v]||v||'-';}
|
||||
function parseResult(note){
|
||||
const m=String(note||'').match(/result=(success|failed|denied)/);
|
||||
return m?m[1]:'';
|
||||
}
|
||||
function resultLabel(r){
|
||||
if(r==='success') return '成功';
|
||||
if(r==='failed') return '失败';
|
||||
if(r==='denied') return '拒绝';
|
||||
return '未标注';
|
||||
}
|
||||
|
||||
function resetFilters(){['fAction','fTarget','fResult','fActor','fFrom','fTo'].forEach(id=>document.getElementById(id).value='');loadAudit();}
|
||||
|
||||
async function loadAudit(){
|
||||
const p=new URLSearchParams();
|
||||
const m={action:qs('fAction'),target_type:qs('fTarget'),result:qs('fResult'),actor_id:qs('fActor'),from:qs('fFrom'),to:qs('fTo')};
|
||||
Object.entries(m).forEach(([k,v])=>{if(v)p.set(k,v)});
|
||||
p.set('limit','200');
|
||||
|
||||
const listEl=document.getElementById('list');
|
||||
listEl.innerHTML='<div class="empty">加载中...</div>';
|
||||
|
||||
try{
|
||||
const out=await api('/api/v1/admin/audit?'+p.toString());
|
||||
const list=Array.isArray(out.logs)?out.logs:[];
|
||||
if(!list.length){listEl.innerHTML='<div class="empty">暂无审计记录</div>';return;}
|
||||
|
||||
listEl.innerHTML=list.map(it=>{
|
||||
const result=parseResult(it.note);
|
||||
const resultClass=result||'success';
|
||||
const note=String(it.note||'').replace(/\s*\|\s*result=(success|failed|denied)\s*$/,'').trim();
|
||||
return `<div class="log-card">
|
||||
<div class="row"><strong>${actionLabel(it.action)}</strong><span class="tag ${resultClass}">${resultLabel(result)}</span></div>
|
||||
<div class="mono" style="margin-top:4px;">#${it.id} · ${esc(it.created_at||'')}</div>
|
||||
<div class="mono" style="margin-top:2px;">操作人: ${it.actor_id} · 目标: ${targetLabel(it.target_type)} (${esc(it.target_id||'')})</div>
|
||||
<div class="note">${esc(note||'(无备注)')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}catch(e){
|
||||
listEl.innerHTML='<div class="empty">加载失败:'+esc(e.message||e)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
function initPermissionUI(){
|
||||
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
|
||||
}
|
||||
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
(async function(){
|
||||
try{ initTheme(); await loadMe(); initPermissionUI(); await loadAudit(); }
|
||||
catch(e){ document.getElementById('list').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>'; }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
80
templates/cf_settings.html
Normal file
80
templates/cf_settings.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cloudflare 配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;
|
||||
--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button{width:28px;padding:0}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:760px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:#0b1220;color:var(--text)}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
small{color:var(--muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>☁️ Cloudflare 配置</div>
|
||||
<div class="right">
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>账号凭据</h3>
|
||||
<div class="row"><label>Account ID</label><input id="account" placeholder="请输入 Account ID"></div>
|
||||
<div class="row"><label>API Token</label><input id="token" placeholder="请输入 API Token"></div>
|
||||
<small>用于查询/修改 DNS & Workers(单账号)</small>
|
||||
<div class="btns">
|
||||
<button onclick="save()">保存</button>
|
||||
<button class="secondary" onclick="load()">刷新</button>
|
||||
</div>
|
||||
<small id="msg"></small>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/cf/settings');
|
||||
document.getElementById('account').value = d.settings?.cf_account_id || '';
|
||||
document.getElementById('token').value = d.settings?.cf_api_token || '';
|
||||
document.getElementById('msg').textContent='已加载';
|
||||
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(){
|
||||
const payload={
|
||||
account_id: document.getElementById('account').value.trim(),
|
||||
api_token: document.getElementById('token').value.trim()
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/cf/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('msg').textContent='已保存';
|
||||
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
|
||||
}
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
413
templates/channels.html
Normal file
413
templates/channels.html
Normal file
@@ -0,0 +1,413 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>渠道配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
.header { background: var(--header-bg); color: #fff; padding: 18px 20px; position: sticky; top: 0; z-index: 100; box-shadow: 0 2px 10px rgba(0,0,0,.15); display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.header a,.header button { color: #fff; text-decoration:none; background: rgba(255,255,255,.2); padding:6px 10px; border-radius:8px; font-size:13px; border:none; cursor:pointer; }
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.header a:hover { background: rgba(255,255,255,.35); }
|
||||
.wrap { max-width: 760px; margin: 0 auto; padding: 14px; }
|
||||
.toolbar { display:flex; gap:8px; margin-bottom:10px; flex-wrap:wrap; }
|
||||
.tip { color:var(--muted); font-size:12px; margin-bottom:10px; }
|
||||
.card { background:var(--card); border-radius:6px; padding:14px; margin-bottom:10px; border:1px solid var(--border); box-shadow:none; }
|
||||
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
|
||||
.row label { width:140px; color:var(--muted); font-size:13px; }
|
||||
.row input, .row textarea { flex:1; min-width:220px; padding:8px; border:1px solid var(--border); border-radius:6px; font-size:13px; background:var(--card); color:var(--text); }
|
||||
.row textarea { min-height:74px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.btns { display:flex; gap:8px; margin-top:8px; flex-wrap:wrap; }
|
||||
button { border:none; border-radius:6px; padding:8px 12px; cursor:pointer; color:#fff; font-size:13px; }
|
||||
button:disabled { opacity:.65; cursor:not-allowed; }
|
||||
.btn-apply, .btn-save, .btn-publish, .btn-test, .btn-reload, .btn-enable { background:var(--accent); }
|
||||
.btn-apply:hover, .btn-save:hover, .btn-publish:hover, .btn-test:hover, .btn-reload:hover, .btn-enable:hover { background:var(--accent-hover); }
|
||||
.btn-disable { background:#9b2c2c; }
|
||||
.btn-disable:hover { background:#7f1d1d; }
|
||||
.btn-ghost { background:transparent; color:var(--muted); border:1px solid var(--border); }
|
||||
.btn-ghost:hover { background:rgba(0,0,0,.04); }
|
||||
.badge { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; }
|
||||
.state { display:inline-block; font-size:12px; border-radius:6px; padding:2px 8px; margin-left:6px; }
|
||||
.ok { background:#dcfce7; color:#166534; }
|
||||
.error { background:#fee2e2; color:#991b1b; }
|
||||
.disabled { background:#e5e7eb; color:#374151; }
|
||||
small { color:var(--muted); }
|
||||
.hidden{display:none !important;}
|
||||
.advanced{border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;}
|
||||
|
||||
@media(max-width:640px){
|
||||
.header { padding: 14px 12px 10px; align-items:flex-start; flex-direction:column; }
|
||||
.header > div:last-child { display:flex; gap:8px; }
|
||||
.wrap { padding:12px; }
|
||||
.toolbar button { flex: 1 1 calc(50% - 4px); }
|
||||
.row label { width:100%; }
|
||||
.row input, .row textarea { min-width:100%; }
|
||||
.btns button { flex: 1 1 calc(50% - 4px); }
|
||||
}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🦞 渠道配置中心(草稿/发布) · {{.version}}</div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<button id="btnDisableAll" class="btn-disable" onclick="disableAll()">一键全部关闭</button>
|
||||
<button id="btnReload" class="btn-reload" onclick="reloadRuntime()">热加载运行参数</button>
|
||||
</div>
|
||||
<div class="tip">填写需要的参数即可(每项一个输入框)。高级 JSON 已折叠,默认不需要碰。</div>
|
||||
<div id="app"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let me=null;
|
||||
const app = document.getElementById('app');
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const r = await fetch(url, options);
|
||||
const out = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(out.message || ('HTTP ' + r.status));
|
||||
return out?.data || {};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
|
||||
function renderError(msg) {
|
||||
app.innerHTML = `<div class="card" style="border:1px solid #fecaca;background:#fef2f2;color:#991b1b;">${msg}</div>`;
|
||||
}
|
||||
|
||||
function pretty(objStr) {
|
||||
try { return JSON.stringify(JSON.parse(objStr || '{}'), null, 2); } catch { return '{}'; }
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const s = (status || 'disabled');
|
||||
const cls = s === 'ok' ? 'ok' : (s === 'error' ? 'error' : 'disabled');
|
||||
return `<span class="badge ${cls}">${s}</span>`;
|
||||
}
|
||||
|
||||
function runtimeState(ch) {
|
||||
if (!ch.enabled) return '<span class="state disabled">已关闭</span>';
|
||||
if ((ch.status || '').toLowerCase() === 'ok') return '<span class="state ok">运行中</span>';
|
||||
if ((ch.status || '').toLowerCase() === 'error') return '<span class="state error">配置异常</span>';
|
||||
return '<span class="state disabled">待检测</span>';
|
||||
}
|
||||
|
||||
function parseJSONSafe(text) {
|
||||
try { return JSON.parse(text || '{}'); } catch { return null; }
|
||||
}
|
||||
|
||||
function getFieldDefs(platform){
|
||||
if(platform==='telegram'){
|
||||
return [
|
||||
{key:'token', label:'Telegram Bot Token'}
|
||||
];
|
||||
}
|
||||
if(platform==='qqbot_official'){
|
||||
return [
|
||||
{key:'appid', label:'QQ Bot AppID'},
|
||||
{key:'secret', label:'QQ Bot Secret'}
|
||||
];
|
||||
}
|
||||
if(platform==='feishu'){
|
||||
return [
|
||||
{key:'app_id', label:'飞书 AppID'},
|
||||
{key:'app_secret', label:'飞书 AppSecret'},
|
||||
{key:'verification_token', label:'飞书 VerificationToken(可选)'},
|
||||
{key:'encrypt_key', label:'飞书 EncryptKey(可选)'}
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function fetchChannels() {
|
||||
const out = await api('/api/v1/admin/channels');
|
||||
const data = out?.channels;
|
||||
if (!Array.isArray(data)) throw new Error('渠道返回格式异常');
|
||||
return data;
|
||||
}
|
||||
|
||||
function render(channels) {
|
||||
app.innerHTML = channels.map(ch => {
|
||||
const draftCfg = pretty(ch.draft_config_json || ch.config_json);
|
||||
const draftSec = pretty(ch.draft_secrets || ch.secrets);
|
||||
const secObj = parseJSONSafe(draftSec) || {};
|
||||
const hasDraft = ch.has_draft ? '<span class="badge" style="background:#fef3c7;color:#92400e">draft</span>' : '';
|
||||
const fields = getFieldDefs(ch.platform);
|
||||
const fieldsHtml = fields.map(f => {
|
||||
let v = secObj[f.key] || '';
|
||||
if (String(v).trim() === '***') { v = ''; }
|
||||
return `<div class="row"><label>${esc(f.label)}</label><input class="field" data-key="${esc(f.key)}" value="${esc(v)}" placeholder="留空表示不修改"></div>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="card" data-platform="${esc(ch.platform)}">
|
||||
<h3>${esc(ch.name || ch.platform)} ${statusBadge(ch.status)} ${runtimeState(ch)} ${hasDraft}</h3>
|
||||
<small>平台:${esc(ch.platform)} | 发布:${esc(ch.published_at || '-')} | 最近检测:${esc(ch.last_check_at || '-')}</small>
|
||||
<div class="row"><label>启用</label><input type="checkbox" class="enabled" ${ch.enabled ? 'checked' : ''}></div>
|
||||
<div class="row"><label>显示名称</label><input class="name" value="${esc(ch.name||'')}"></div>
|
||||
${fieldsHtml}
|
||||
<div class="btns">
|
||||
<button class="btn-apply" onclick="applyNow('${esc(ch.platform)}')">保存并立即生效</button>
|
||||
<button class="btn-save" onclick="saveDraft('${esc(ch.platform)}')">保存草稿</button>
|
||||
<button class="btn-publish" onclick="publishDraft('${esc(ch.platform)}')">发布草稿</button>
|
||||
<button class="btn-test" onclick="testConn('${esc(ch.platform)}')">测试连接</button>
|
||||
${ch.enabled ? `<button class="btn-disable" onclick="toggleChannel('${esc(ch.platform)}', false)">关闭通道</button>` : `<button class="btn-enable" onclick="toggleChannel('${esc(ch.platform)}', true)">开启通道</button>`}
|
||||
<button class="btn-ghost" onclick="toggleAdvanced('${esc(ch.platform)}')">高级 JSON</button>
|
||||
</div>
|
||||
<div class="advanced hidden">
|
||||
<div class="row"><label>配置 JSON</label><textarea class="config">${draftCfg}</textarea></div>
|
||||
<div class="row"><label>密钥 JSON</label><textarea class="secrets">${draftSec}</textarea></div>
|
||||
</div>
|
||||
<small class="msg"></small>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function collectChannelForm(platform) {
|
||||
const card = document.querySelector(`[data-platform="${platform}"]`);
|
||||
const name = card.querySelector('.name').value.trim();
|
||||
const enabled = card.querySelector('.enabled').checked;
|
||||
const cfgText = card.querySelector('.config') ? card.querySelector('.config').value : '{}';
|
||||
const secText = card.querySelector('.secrets') ? card.querySelector('.secrets').value : '{}';
|
||||
const msg = card.querySelector('.msg');
|
||||
|
||||
const config = parseJSONSafe(cfgText) || {};
|
||||
const secrets = parseJSONSafe(secText) || {};
|
||||
|
||||
if (config === null || secrets === null) {
|
||||
msg.textContent = 'JSON 格式错误,请检查高级 JSON';
|
||||
return null;
|
||||
}
|
||||
|
||||
// 覆盖结构化字段,但保留未知字段
|
||||
let hasSecretChange = false;
|
||||
card.querySelectorAll('.field').forEach(input => {
|
||||
const key = input.getAttribute('data-key');
|
||||
const val = (input.value || '').trim();
|
||||
if (val !== '') {
|
||||
secrets[key] = val;
|
||||
hasSecretChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
const payload = { name, enabled, config };
|
||||
if (hasSecretChange) payload.secrets = secrets;
|
||||
|
||||
return { card, msg, payload };
|
||||
}
|
||||
|
||||
function setCardBusy(card, busy) {
|
||||
if (!card) return;
|
||||
card.querySelectorAll('button').forEach(btn => { btn.disabled = busy; });
|
||||
}
|
||||
|
||||
async function applyNow(platform) {
|
||||
const f = collectChannelForm(platform);
|
||||
if (!f) return;
|
||||
const { card, msg, payload } = f;
|
||||
const applyBtn = card.querySelector('.btn-apply');
|
||||
const oldText = applyBtn ? applyBtn.textContent : '';
|
||||
|
||||
setCardBusy(card, true);
|
||||
if (applyBtn) applyBtn.textContent = '生效中...';
|
||||
|
||||
try {
|
||||
msg.textContent = '保存并生效中...';
|
||||
await api('/api/v1/admin/channels/' + platform + '/apply', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = '已生效';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '失败:' + (e && e.message ? e.message : e);
|
||||
} finally {
|
||||
setCardBusy(card, false);
|
||||
if (applyBtn) applyBtn.textContent = oldText || '保存并立即生效';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft(platform) {
|
||||
const f = collectChannelForm(platform);
|
||||
if (!f) return;
|
||||
const { msg, payload } = f;
|
||||
|
||||
try {
|
||||
await api('/api/v1/admin/channels/' + platform, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
msg.textContent = '草稿已保存';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '保存失败:' + (e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishDraft(platform) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
await api('/api/v1/admin/channels/' + platform + '/publish', { method: 'POST' });
|
||||
msg.textContent = '发布成功,建议点“热加载运行参数”';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '发布失败:' + (e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConn(platform) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
msg.textContent = '正在测试...';
|
||||
const out = await api('/api/v1/admin/channels/' + platform + '/test', { method: 'POST' });
|
||||
msg.textContent = `测试结果:${out.status || 'unknown'} ${out.detail ? ' / ' + out.detail : ''}`;
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = '测试失败:' + (e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleChannel(platform, enable) {
|
||||
const msg = msgOf(platform);
|
||||
try {
|
||||
msg.textContent = enable ? '正在开启...' : '正在关闭...';
|
||||
await api('/api/v1/admin/channels/' + platform + (enable ? '/enable' : '/disable'), { method: 'POST' });
|
||||
msg.textContent = enable ? '已开启(请点热加载生效)' : '已关闭(请点热加载生效)';
|
||||
await reload();
|
||||
} catch (e) {
|
||||
msg.textContent = (enable ? '开启失败:' : '关闭失败:') + (e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadRuntime() {
|
||||
try {
|
||||
const out = await api('/api/v1/admin/channels/reload', { method: 'POST' });
|
||||
alert('热加载成功:' + (out.detail || 'ok'));
|
||||
await reload();
|
||||
} catch (e) {
|
||||
alert('热加载失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
async function disableAll() {
|
||||
if (!confirm('确认要关闭所有通道吗?')) return;
|
||||
try {
|
||||
const out = await api('/api/v1/admin/channels/disable-all', { method: 'POST' });
|
||||
alert('已关闭通道数:' + (out.affected || 0) + ',请点热加载生效。');
|
||||
await reload();
|
||||
} catch (e) {
|
||||
alert('批量关闭失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
function msgOf(platform) {
|
||||
return document.querySelector(`[data-platform="${platform}"] .msg`);
|
||||
}
|
||||
|
||||
function toggleAdvanced(platform){
|
||||
const card = document.querySelector(`[data-platform="${platform}"]`);
|
||||
if(!card) return;
|
||||
const adv = card.querySelector('.advanced');
|
||||
if(adv) adv.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
function initPermissionUI(){
|
||||
document.getElementById('btnReload').classList.toggle('hidden',!can('can_edit_channels'));
|
||||
document.getElementById('btnDisableAll').classList.toggle('hidden',!can('can_edit_channels'));
|
||||
}
|
||||
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
try {
|
||||
const channels = await fetchChannels();
|
||||
render(channels);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
renderError('页面加载失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
renderError('前端脚本异常:' + (e && e.message ? e.message : 'unknown'));
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const msg = e && e.reason && e.reason.message ? e.reason.message : String(e.reason || 'unknown');
|
||||
renderError('前端请求异常:' + msg);
|
||||
});
|
||||
|
||||
(async function(){
|
||||
try{ initTheme(); await loadMe(); initPermissionUI(); await reload(); }
|
||||
catch(e){ renderError('初始化失败:'+(e.message||e)); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
207
templates/cpa_settings.html
Normal file
207
templates/cpa_settings.html
Normal file
@@ -0,0 +1,207 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CPA 配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;
|
||||
--accent:#ee5a24;--accent-hover:#d63031;--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;
|
||||
--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
|
||||
.header button{width:28px;padding:0;transition:transform .2s ease}
|
||||
.header button.spin{transform:rotate(180deg)}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
button.danger{background:#9b1c1c}
|
||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
||||
small{color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🔧 CPA 配置 · {{.version}}</div>
|
||||
<div class="right">
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>CPA 管理接口</h3>
|
||||
<div class="row"><label>CPA Management Base</label><input id="base" placeholder="https://cpa.pao.xx.kg/v0/management"></div>
|
||||
<div class="row"><label>CPA Management Token</label><input id="token" placeholder="请输入 Token"></div>
|
||||
<small>用于访问 CPA 管理接口(Bearer Token)</small>
|
||||
<div class="btns">
|
||||
<button onclick="save()">保存</button>
|
||||
<button class="secondary" onclick="load()">刷新</button>
|
||||
</div>
|
||||
<small id="msg"></small>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>目标主机(Ops Targets)</h3>
|
||||
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
|
||||
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
|
||||
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
|
||||
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
|
||||
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
|
||||
<div class="btns"><button onclick="createTarget()">新增</button><button class="secondary" onclick="loadTargets()">刷新列表</button></div>
|
||||
<small id="tmsg"></small>
|
||||
<div style="margin-top:10px;">
|
||||
<table class="table" style="width:100%;border-collapse:collapse;font-size:13px;">
|
||||
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/cpa/settings');
|
||||
document.getElementById('token').value = d.settings?.cpa_management_token || '';
|
||||
document.getElementById('base').value = d.settings?.cpa_management_base || '';
|
||||
document.getElementById('msg').textContent='已加载';
|
||||
}catch(e){document.getElementById('msg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(){
|
||||
const token=document.getElementById('token').value.trim();
|
||||
const base=document.getElementById('base').value.trim();
|
||||
try{
|
||||
await api('/api/v1/admin/cpa/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({management_token:token,management_base:base})});
|
||||
document.getElementById('msg').textContent='已保存';
|
||||
}catch(e){document.getElementById('msg').textContent='保存失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function loadTargets(){
|
||||
try{
|
||||
const d=await api('/api/v1/admin/ops/targets');
|
||||
const list=Array.isArray(d.targets)?d.targets:[];
|
||||
const tbody=document.getElementById('tbody');
|
||||
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
|
||||
tbody.innerHTML=list.map(t=>`<tr>
|
||||
<td>${esc(t.name)}</td>
|
||||
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
|
||||
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
|
||||
<td>
|
||||
<button class="secondary" onclick="saveTarget(${t.id})">保存</button>
|
||||
<button class="danger" onclick="toggleTarget(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}catch(e){document.getElementById('tmsg').textContent='加载失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function createTarget(){
|
||||
const payload={
|
||||
name:document.getElementById('name').value.trim(),
|
||||
host:document.getElementById('host').value.trim(),
|
||||
port:parseInt(document.getElementById('port').value||'22',10),
|
||||
user:document.getElementById('user').value.trim(),
|
||||
enabled:document.getElementById('enabled').checked
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('tmsg').textContent='已新增';
|
||||
await loadTargets();
|
||||
}catch(e){document.getElementById('tmsg').textContent='失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function saveTarget(id){
|
||||
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
|
||||
const payload={enabled:true};
|
||||
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
|
||||
payload.port=parseInt(payload.port||'22',10);
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
await loadTargets();
|
||||
}catch(e){alert('保存失败:'+(e.message||e));}
|
||||
}
|
||||
async function toggleTarget(id, enable){
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
|
||||
await loadTargets();
|
||||
}catch(e){alert('失败:'+(e.message||e));}
|
||||
}
|
||||
function setThemeIcon(theme){
|
||||
const icon=document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
if(theme==='dark'){
|
||||
// 月亮:同线条风格
|
||||
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
||||
}else{
|
||||
// 太阳:保留原样式
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle>'+
|
||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
||||
}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
||||
else{document.documentElement.removeAttribute('data-theme');}
|
||||
localStorage.setItem('theme',theme);
|
||||
setThemeIcon(theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
const next = cur==='light'?'dark':'light';
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
||||
applyTheme(next);
|
||||
}
|
||||
(function(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.addEventListener('click',toggleTheme)}
|
||||
load();
|
||||
loadTargets();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
257
templates/index.html
Normal file
257
templates/index.html
Normal file
@@ -0,0 +1,257 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🛠️ Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
--chip:#f3f4f6;
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
--chip:#111827;
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;align-items:center;justify-content:space-between;gap:12px}
|
||||
.header h1{font-size:22px}
|
||||
.header .subtitle{font-size:12px;opacity:.9}
|
||||
.header .right{display:flex;align-items:center;gap:8px}
|
||||
.header .right a,.header .right button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center}
|
||||
.header .right button{width:28px;padding:0;transition:transform .2s ease}
|
||||
.header .right button.spin{transform:rotate(180deg)}
|
||||
.header .right button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none}
|
||||
.card h3{font-size:14px;margin-bottom:10px}
|
||||
.stat{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;font-size:13px;color:var(--muted)}
|
||||
.stat b{font-size:18px;color:var(--accent)}
|
||||
|
||||
.section{margin-top:12px}
|
||||
.section-title{font-size:13px;color:var(--muted);margin:10px 2px}
|
||||
.tags{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.tag{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px;background:var(--card)}
|
||||
.tag .dot{width:8px;height:8px;border-radius:50%}
|
||||
.dot.ok{background:#22c55e}.dot.err{background:#ef4444}.dot.off{background:#9ca3af}
|
||||
|
||||
.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
||||
.actions a{display:inline-block;text-decoration:none;background:var(--accent);color:#fff;border-radius:6px;padding:8px 12px;font-size:13px;border:1px solid rgba(0,0,0,.06)}
|
||||
.actions a.secondary{background:#6b7280}
|
||||
.actions a.hidden{display:none}
|
||||
|
||||
.list{display:grid;gap:8px}
|
||||
.job{border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;background:var(--card)}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.pending{background:#fef3c7;color:#92400e}.running{background:#dbeafe;color:#1e3a8a}.success{background:#dcfce7;color:#166534}.failed{background:#fee2e2;color:#991b1b}.cancelled{background:#e5e7eb;color:#374151}
|
||||
|
||||
.empty{padding:30px;text-align:center;color:var(--muted)}
|
||||
|
||||
@media(max-width:720px){
|
||||
.grid{grid-template-columns:1fr}
|
||||
.header{flex-direction:column;align-items:flex-start}
|
||||
.header .right{align-self:flex-end}
|
||||
}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🛠️ Ops-Assistant</h1>
|
||||
<div class="subtitle">{{.version}}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>任务概览</h3>
|
||||
<div class="stat"><span>Pending</span><b id="sPending">0</b></div>
|
||||
<div class="stat"><span>Running</span><b id="sRunning">0</b></div>
|
||||
<div class="stat"><span>Success</span><b id="sSuccess">0</b></div>
|
||||
<div class="stat"><span>Failed</span><b id="sFailed">0</b></div>
|
||||
<div class="stat"><span>Cancelled</span><b id="sCancelled">0</b></div>
|
||||
<div class="actions">
|
||||
<a id="btnOps" href="/ops" class="hidden">🛠️ 任务中心</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>模块状态</h3>
|
||||
<div class="tags" id="modulesTag"></div>
|
||||
<div class="actions">
|
||||
<a id="btnCPA" href="/cpa" class="secondary hidden">🔧 CPA 配置</a>
|
||||
<a id="btnCF" href="/cf" class="secondary hidden">☁️ CF 配置</a>
|
||||
<a id="btnAI" href="/ai" class="secondary hidden" style="display:none">🤖 AI 配置</a>
|
||||
<a id="btnModules" href="#" class="secondary hidden" onclick="toggleModulesPanel();return false;">⚙️ 模块开关</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>通道状态</h3>
|
||||
<div class="tags" id="channelsTag"></div>
|
||||
<div class="actions">
|
||||
<a id="btnChannels" href="/channels" class="hidden">🔌 渠道配置</a>
|
||||
<a id="btnAudit" href="/audit" class="secondary hidden">🧾 审计日志</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">最近任务</div>
|
||||
<div id="recentJobs" class="list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let me=null;
|
||||
const state={overview:null};
|
||||
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
function initUI(){
|
||||
document.getElementById('btnOps').classList.toggle('hidden',!can('can_view_ops'));
|
||||
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
|
||||
document.getElementById('btnCPA').classList.toggle('hidden',!can('can_view_flags'));
|
||||
document.getElementById('btnCF').classList.toggle('hidden',!can('can_view_flags'));
|
||||
document.getElementById('btnAI').classList.toggle('hidden',!can('can_view_flags'));
|
||||
document.getElementById('btnAudit').classList.toggle('hidden',!can('can_view_audit'));
|
||||
document.getElementById('btnModules').classList.toggle('hidden',!can('can_view_flags'));
|
||||
}
|
||||
|
||||
function renderOverview(){
|
||||
const ov=state.overview||{};
|
||||
const sc=ov.jobs?.status_count||{};
|
||||
document.getElementById('sPending').textContent=sc.pending??0;
|
||||
document.getElementById('sRunning').textContent=sc.running??0;
|
||||
document.getElementById('sSuccess').textContent=sc.success??0;
|
||||
document.getElementById('sFailed').textContent=sc.failed??0;
|
||||
document.getElementById('sCancelled').textContent=sc.cancelled??0;
|
||||
|
||||
const mods=Array.isArray(ov.modules)?ov.modules:[];
|
||||
document.getElementById('modulesTag').innerHTML=mods.length?mods.map(m=>{
|
||||
const dot=m.enabled?'ok':'off';
|
||||
return `<span class="tag"><span class="dot ${dot}"></span>${esc(m.module)}</span>`;
|
||||
}).join(''):'<div class="empty">暂无模块</div>';
|
||||
|
||||
const chs=Array.isArray(ov.channels)?ov.channels:[];
|
||||
document.getElementById('channelsTag').innerHTML=chs.length?chs.map(c=>{
|
||||
const dot=c.status==='ok'?'ok':(c.enabled?'err':'off');
|
||||
return `<span class="tag"><span class="dot ${dot}"></span>${esc(c.platform)}</span>`;
|
||||
}).join(''):'<div class="empty">暂无通道</div>';
|
||||
|
||||
const jobs=Array.isArray(ov.jobs?.recent)?ov.jobs.recent:[];
|
||||
const box=document.getElementById('recentJobs');
|
||||
if(!jobs.length){ box.innerHTML='<div class="empty">暂无任务</div>'; return; }
|
||||
box.innerHTML=jobs.map(j=>`<div class="job">
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap;">
|
||||
<strong>#${j.id} ${esc(j.command||'')}</strong>
|
||||
<span class="badge ${(j.status||'pending').toLowerCase()}">${esc(j.status||'pending')}</span>
|
||||
</div>
|
||||
<div style="color:#666;margin-top:4px;">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function loadOverview(){
|
||||
state.overview=await api('/api/v1/dashboard/overview');
|
||||
renderOverview();
|
||||
}
|
||||
|
||||
function toggleModulesPanel(){
|
||||
alert('模块开关请进入设置页(后续独立页面/弹窗)');
|
||||
}
|
||||
|
||||
function setThemeIcon(theme){
|
||||
const icon=document.getElementById('themeIcon');
|
||||
if(!icon) return;
|
||||
if(theme==='dark'){
|
||||
icon.innerHTML = '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
||||
}else{
|
||||
icon.innerHTML = '<circle cx="12" cy="12" r="5"></circle>'+
|
||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
||||
}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
setThemeIcon(theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
const next=cur==='light'?'dark':'light';
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
||||
applyTheme(next);
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
(async function(){
|
||||
try{ initTheme(); await loadMe(); initUI(); await loadOverview(); setInterval(loadOverview,15000);}catch(e){
|
||||
document.getElementById('recentJobs').innerHTML='<div class="empty">初始化失败:'+esc(e.message||e)+'</div>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
142
templates/login.html
Normal file
142
templates/login.html
Normal file
@@ -0,0 +1,142 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🛠️ Ops-Assistant - 登录</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f172a;
|
||||
--card:#111827;
|
||||
--text:#e5e7eb;
|
||||
--muted:#9ca3af;
|
||||
--border:#1f2937;
|
||||
--accent:#f97316;
|
||||
--accent2:#ea580c;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: radial-gradient(1200px 600px at 10% 10%, rgba(249,115,22,.18), transparent 60%),
|
||||
radial-gradient(900px 500px at 90% 10%, rgba(234,88,12,.18), transparent 60%),
|
||||
var(--bg);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: var(--card);
|
||||
border-radius: 16px;
|
||||
padding: 36px 30px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
border: 1px solid var(--border);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.35);
|
||||
animation: slideUp .4s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(30px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.login-logo .icon { font-size: 48px; }
|
||||
.login-logo h1 {
|
||||
font-size: 22px;
|
||||
color: var(--text);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.login-logo .subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
transition: border-color .2s, box-shadow .2s;
|
||||
outline: none;
|
||||
background: #0b1220;
|
||||
color: var(--text);
|
||||
}
|
||||
.form-group input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px rgba(249,115,22,.2);
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity .2s, transform .1s;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.btn-login:hover { opacity: .9; }
|
||||
.btn-login:active { transform: scale(.98); }
|
||||
|
||||
.error-msg {
|
||||
background: rgba(239,68,68,.12);
|
||||
color: #fca5a5;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(239,68,68,.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-card">
|
||||
<div class="login-logo">
|
||||
<div class="icon">🦞</div>
|
||||
<h1>Ops-Assistant</h1>
|
||||
<div class="subtitle">{{.version}}</div>
|
||||
</div>
|
||||
|
||||
{{if .error}}
|
||||
<div class="error-msg">{{.error}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input type="text" name="username" placeholder="请输入用户名" autocomplete="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input type="password" name="password" placeholder="请输入密码" autocomplete="current-password" required>
|
||||
</div>
|
||||
<button class="btn-login" type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
229
templates/ops.html
Normal file
229
templates/ops.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🛠️ OPS任务 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#f5f7fb;
|
||||
--text:#222;
|
||||
--card:#fff;
|
||||
--border:#e5e7eb;
|
||||
--muted:#6b7280;
|
||||
--accent:#ee5a24;
|
||||
--accent-hover:#d63031;
|
||||
--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);
|
||||
}
|
||||
[data-theme="dark"]{
|
||||
--bg:#0f172a;
|
||||
--text:#e5e7eb;
|
||||
--card:#111827;
|
||||
--border:#1f2937;
|
||||
--muted:#9ca3af;
|
||||
--accent:#f97316;
|
||||
--accent-hover:#ea580c;
|
||||
--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);
|
||||
}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer}
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:14px}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
input,select{padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
button.danger{background:#9b1c1c}
|
||||
.card{background:var(--card);border-radius:6px;padding:12px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.pending{background:#fef3c7;color:#92400e}.running{background:#dbeafe;color:#1e3a8a}.success{background:#dcfce7;color:#166534}.failed{background:#fee2e2;color:#991b1b}.cancelled{background:#e5e7eb;color:#374151}
|
||||
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);word-break:break-all}
|
||||
pre{background:#0b1020;color:#d1d5db;border-radius:6px;padding:8px;overflow:auto;font-size:12px;max-height:220px}
|
||||
.small{font-size:12px;color:var(--muted)}
|
||||
.empty{padding:24px;text-align:center;color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🛠️ OPS任务面板 · {{.version}}</div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="toolbar">
|
||||
<select id="qStatus">
|
||||
<option value="">全部状态</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="cancelled">cancelled</option>
|
||||
</select>
|
||||
<input id="qTarget" placeholder="target (如 hwsg)">
|
||||
<input id="qRunbook" placeholder="runbook (如 cpa_usage_backup)">
|
||||
<input id="qRequest" placeholder="request_id">
|
||||
<input id="qOperator" placeholder="operator (user_id)">
|
||||
<input id="qFrom" placeholder="from (RFC3339)">
|
||||
<input id="qTo" placeholder="to (RFC3339)">
|
||||
<button onclick="loadJobs()">查询</button>
|
||||
<button class="secondary" onclick="resetFilter()">重置</button>
|
||||
</div>
|
||||
<div id="jobs"></div>
|
||||
</div>
|
||||
<script>
|
||||
let me=null;
|
||||
let pollTimer=null;
|
||||
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
function can(k){return !!(me&&me.effective_capabilities&&me.effective_capabilities[k]);}
|
||||
function statusTag(s){const k=(s||'pending').toLowerCase();return `<span class="badge ${k}">${esc(k)}</span>`;}
|
||||
|
||||
async function loadMe(){
|
||||
const r=await fetch('/api/v1/me');
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||'读取用户失败');
|
||||
me=out?.data||{};
|
||||
}
|
||||
|
||||
function buildQuery(){
|
||||
const q={
|
||||
status:document.getElementById('qStatus').value.trim(),
|
||||
target:document.getElementById('qTarget').value.trim(),
|
||||
runbook:document.getElementById('qRunbook').value.trim(),
|
||||
request_id:document.getElementById('qRequest').value.trim(),
|
||||
operator:document.getElementById('qOperator').value.trim(),
|
||||
from:document.getElementById('qFrom').value.trim(),
|
||||
to:document.getElementById('qTo').value.trim(),
|
||||
limit:'50'
|
||||
};
|
||||
const qs=Object.entries(q).filter(([,v])=>v).map(([k,v])=>k+'='+encodeURIComponent(v)).join('&');
|
||||
return '/api/v1/ops/jobs'+(qs?'?'+qs:'');
|
||||
}
|
||||
|
||||
async function loadJobs(){
|
||||
const box=document.getElementById('jobs');
|
||||
box.innerHTML='<div class="card">加载中...</div>';
|
||||
try{
|
||||
const data=await api(buildQuery());
|
||||
const jobs=Array.isArray(data.jobs)?data.jobs:[];
|
||||
if(!jobs.length){box.innerHTML='<div class="card empty">暂无任务</div>';return;}
|
||||
box.innerHTML=jobs.map(j=>`<div class="card">
|
||||
<div class="row"><strong>#${j.id} ${esc(j.command||'')}</strong>${statusTag(j.status)}</div>
|
||||
<div class="small">runbook=${esc(j.runbook||'-')} · target=${esc(j.target||'-')} · risk=${esc(j.risk_level||'-')}</div>
|
||||
<div class="mono">request=${esc(j.request_id||'-')} · start=${esc(j.started_at||'-')} · end=${esc(j.ended_at||'-')}</div>
|
||||
<div class="toolbar" style="margin-top:8px;">
|
||||
<button class="secondary" onclick="viewDetail(${j.id})">查看步骤</button>
|
||||
${can('can_cancel_ops')?`<button class="danger" onclick="cancelJob(${j.id},'${esc(j.status||'')}')">取消</button>`:''}
|
||||
${can('can_retry_ops')?`<button onclick="retryJob(${j.id},'${esc(j.status||'')}')">重试</button>`:''}
|
||||
</div>
|
||||
<div id="detail-${j.id}" class="small"></div>
|
||||
</div>`).join('');
|
||||
}catch(e){box.innerHTML='<div class="card">加载失败:'+esc(e.message||e)+'</div>';}
|
||||
schedulePoll();
|
||||
}
|
||||
|
||||
function schedulePoll(){
|
||||
if(pollTimer) clearTimeout(pollTimer);
|
||||
pollTimer=setTimeout(async ()=>{
|
||||
try{ await loadJobs(); }catch(e){}
|
||||
},8000);
|
||||
}
|
||||
|
||||
async function viewDetail(id){
|
||||
const el=document.getElementById('detail-'+id);
|
||||
el.textContent='加载步骤中...';
|
||||
try{
|
||||
const d=await api('/api/v1/ops/jobs/'+id);
|
||||
const steps=Array.isArray(d.steps)?d.steps:[];
|
||||
const stats=d.step_stats||{};
|
||||
const total=d.step_total||steps.length||0;
|
||||
const dur=d.duration||{};
|
||||
const head=`<div class="small" style="margin:6px 0;">steps=${total} · running=${stats.running||0} · success=${stats.success||0} · failed=${stats.failed||0} · job_ms=${dur.job_ms||0}</div>`;
|
||||
if(!steps.length){el.innerHTML=head+'无步骤';return;}
|
||||
el.innerHTML=head+steps.map(s=>`<div style="margin-top:6px;padding:6px;border:1px solid #eee;border-radius:8px;">
|
||||
<div><strong>${esc(s.step_id)}</strong> (${esc(s.action)}) ${statusTag(s.status)}</div>
|
||||
<div class="mono">rc=${s.rc} · ${esc(s.started_at||'')} -> ${esc(s.ended_at||'')}</div>
|
||||
<details><summary>stdout/stderr</summary><pre>${esc((s.stdout_tail||'')+'\n---\n'+(s.stderr_tail||''))}</pre></details>
|
||||
</div>`).join('');
|
||||
}catch(e){el.textContent='加载失败:'+(e.message||e);}
|
||||
}
|
||||
|
||||
async function cancelJob(id,status){
|
||||
if(!['pending','running'].includes(String(status||'').toLowerCase())){alert('仅 pending/running 可取消');return;}
|
||||
const reason=prompt('请输入取消原因(必填)')||'';
|
||||
if(!reason.trim()){alert('取消原因不能为空');return;}
|
||||
if(!confirm('确认取消任务 #'+id+' ?')) return;
|
||||
try{await api('/api/v1/ops/jobs/'+id+'/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已取消');loadJobs();}
|
||||
catch(e){alert('取消失败:'+(e.message||e));}
|
||||
}
|
||||
|
||||
async function retryJob(id,status){
|
||||
if(String(status||'').toLowerCase()!=='failed'){alert('仅 failed 可重试');return;}
|
||||
const reason=prompt('请输入重试原因(必填)')||'';
|
||||
if(!reason.trim()){alert('重试原因不能为空');return;}
|
||||
try{const d=await api('/api/v1/ops/jobs/'+id+'/retry',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason})});alert('已重试,新任务ID='+d.new_job_id);loadJobs();}
|
||||
catch(e){alert('重试失败:'+(e.message||e));}
|
||||
}
|
||||
|
||||
function resetFilter(){
|
||||
document.getElementById('qStatus').value='';
|
||||
document.getElementById('qTarget').value='';
|
||||
document.getElementById('qRunbook').value='';
|
||||
document.getElementById('qRequest').value='';
|
||||
document.getElementById('qOperator').value='';
|
||||
document.getElementById('qFrom').value='';
|
||||
document.getElementById('qTo').value='';
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
}else{
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
function initTheme(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
||||
}
|
||||
|
||||
(async function(){
|
||||
try{initTheme();await loadMe();await loadJobs();}catch(e){document.getElementById('jobs').innerHTML='<div class="card">初始化失败:'+esc(e.message||e)+'</div>';}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
145
templates/ops_targets.html
Normal file
145
templates/ops_targets.html
Normal file
@@ -0,0 +1,145 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>目标主机配置 - Ops-Assistant</title>
|
||||
<style>
|
||||
:root{--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;--accent:#ee5a24;--accent-hover:#d63031;--header-bg:linear-gradient(135deg,#ff6b6b,#ee5a24);}
|
||||
[data-theme="dark"]{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:6px 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer}
|
||||
.header button{display:inline-flex;align-items:center;justify-content:center}
|
||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}
|
||||
.row label{width:120px;color:var(--muted);font-size:13px}
|
||||
.row input{flex:1;min-width:160px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:var(--card);color:var(--text)}
|
||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
||||
button.secondary{background:#6b7280}
|
||||
button.danger{background:#9b1c1c}
|
||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
||||
small{color:var(--muted)}
|
||||
.theme-hidden{display:none !important;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div>🎯 目标主机配置 · {{.version}}</div>
|
||||
<div>
|
||||
<button id="themeToggle" class="theme-hidden" title="切换主题" aria-label="切换主题">
|
||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<a href="/">返回首页</a>
|
||||
<a href="/logout">退出</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
<h3>新增目标</h3>
|
||||
<div class="row"><label>Name</label><input id="name" placeholder="如 hwsg"></div>
|
||||
<div class="row"><label>Host</label><input id="host" placeholder="如 124.243.132.158"></div>
|
||||
<div class="row"><label>Port</label><input id="port" placeholder="22"></div>
|
||||
<div class="row"><label>User</label><input id="user" placeholder="root"></div>
|
||||
<div class="row"><label>Enabled</label><input id="enabled" type="checkbox" checked></div>
|
||||
<div class="row"><button onclick="create()">新增</button><small id="msg"></small></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>已有目标</h3>
|
||||
<table class="table">
|
||||
<thead><tr><th>Name</th><th>Host</th><th>Port</th><th>User</th><th>Enabled</th><th>操作</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
async function api(url,opt={}){
|
||||
const r=await fetch(url,opt);
|
||||
const out=await r.json().catch(()=>({}));
|
||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
||||
return out?.data||{};
|
||||
}
|
||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
||||
async function load(){
|
||||
const d=await api('/api/v1/admin/ops/targets');
|
||||
const list=Array.isArray(d.targets)?d.targets:[];
|
||||
const tbody=document.getElementById('tbody');
|
||||
if(!list.length){tbody.innerHTML='<tr><td colspan="6"><small>暂无目标</small></td></tr>';return;}
|
||||
tbody.innerHTML=list.map(t=>`<tr>
|
||||
<td>${esc(t.name)}</td>
|
||||
<td><input data-id="${t.id}" data-field="host" value="${esc(t.host)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="port" value="${esc(t.port)}"></td>
|
||||
<td><input data-id="${t.id}" data-field="user" value="${esc(t.user)}"></td>
|
||||
<td>${t.enabled?'<span class="badge on">ON</span>':'<span class="badge off">OFF</span>'}</td>
|
||||
<td>
|
||||
<button class="secondary" onclick="save(${t.id})">保存</button>
|
||||
<button class="danger" onclick="toggle(${t.id},${t.enabled?0:1})">${t.enabled?'禁用':'启用'}</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
async function create(){
|
||||
const payload={
|
||||
name:document.getElementById('name').value.trim(),
|
||||
host:document.getElementById('host').value.trim(),
|
||||
port:parseInt(document.getElementById('port').value||'22',10),
|
||||
user:document.getElementById('user').value.trim(),
|
||||
enabled:document.getElementById('enabled').checked
|
||||
};
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
document.getElementById('msg').textContent='已新增';
|
||||
await load();
|
||||
}catch(e){document.getElementById('msg').textContent='失败:'+esc(e.message||e);}
|
||||
}
|
||||
async function save(id){
|
||||
const inputs=[...document.querySelectorAll(`input[data-id="${id}"]`)];
|
||||
const payload={enabled:true};
|
||||
inputs.forEach(i=>{payload[i.dataset.field]=i.value.trim();});
|
||||
payload.port=parseInt(payload.port||'22',10);
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
||||
await load();
|
||||
}catch(e){alert('保存失败:'+(e.message||e));}
|
||||
}
|
||||
async function toggle(id, enable){
|
||||
try{
|
||||
await api('/api/v1/admin/ops/targets/'+id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:!!enable})});
|
||||
await load();
|
||||
}catch(e){alert('失败:'+(e.message||e));}
|
||||
}
|
||||
function applyTheme(theme){
|
||||
if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
||||
else{document.documentElement.removeAttribute('data-theme');}
|
||||
localStorage.setItem('theme',theme);
|
||||
}
|
||||
function toggleTheme(){
|
||||
const cur=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(cur==='light'?'dark':'light');
|
||||
}
|
||||
(function(){
|
||||
const saved=localStorage.getItem('theme')||'dark';
|
||||
applyTheme(saved);
|
||||
const btn=document.getElementById('themeToggle');
|
||||
if(btn){btn.addEventListener('click',toggleTheme)}
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
version/version.go
Normal file
22
version/version.go
Normal file
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user