Compare commits
2
Commits
master
..
d83e25e95f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d83e25e95f | ||
|
|
81deba4766 |
-12
@@ -1,19 +1,10 @@
|
|||||||
# 二进制文件
|
# 二进制文件
|
||||||
ops-assistant
|
ops-assistant
|
||||||
ops-assistant-linux-amd64
|
ops-assistant-linux-amd64
|
||||||
/main
|
|
||||||
/xiaji-go
|
|
||||||
/xiaji-go-linux-amd64
|
|
||||||
/dist/
|
|
||||||
|
|
||||||
# 数据库
|
# 数据库
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# 运行时产物(日志 / 备份)
|
|
||||||
/run/
|
|
||||||
/cpa/
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# 配置文件(含敏感信息)
|
# 配置文件(含敏感信息)
|
||||||
config.yaml
|
config.yaml
|
||||||
|
|
||||||
@@ -29,6 +20,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# Go
|
# Go
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# CLI 联调二进制
|
|
||||||
ops-runner
|
|
||||||
|
|||||||
@@ -1,220 +1,94 @@
|
|||||||
# 🛠️ Ops-Assistant
|
# 🛠️ Ops-Assistant
|
||||||
|
|
||||||
多平台 Bot + 自动化运维一体的私人助理服务:**Runbook 工单执行**、**模块化 OPS 命令**(Cloudflare / CPA / 订阅 / cfdav / 邮箱),带 Web 管理后台。
|
一个面向运维场景的 **命令化 + Runbook** 自动化助手。通过 Telegram / QQ / 飞书等入口,触发 **可审计、可回溯、可控风控** 的运维流程。
|
||||||
|
|
||||||
> 当前 `VERSION` 为 `0.0.1`。项目定位为模块化 OPS 助手。
|
> 当前稳定版本:`v0.0.1`
|
||||||
|
|
||||||
## 🆕 框架版更新要点
|
## 🎯 设计目标
|
||||||
|
|
||||||
- **模块化命令框架**:`internal/core/module` 统一命令模板、门控(feature flag / confirm / dry-run)、错误标签(ecode)、结果格式化回传
|
- **固定命令 + 固定流水线**:AI 只做解释,不参与关键执行决策
|
||||||
- **Runbook 执行器增强**(`internal/core/runbook`):
|
- **步骤级审计**:每一步都有状态、输出、耗时与错误记录
|
||||||
- 新增动作:`http.get` / `http.post` / `assert.json`(支持 `expect_success` 严格断言,API 失败不再假阳性)
|
- **高风险能力默认关闭**:必须显式开关 + 二次确认
|
||||||
- 目标主机管理(`ops_targets`,默认 seed:hwsg / wjynl)
|
- **Runbook 驱动**:流程可复用、可回溯、可迁移
|
||||||
- 任务与步骤全程落库(`ops_jobs` / `ops_job_steps`),Web 后台可查
|
|
||||||
- **查询类命令结果直接回传**(此前仅回执 job 号)
|
|
||||||
- **CF 模块增强**:DNS 查询支持**传域名自动解析 zone_id**;`dns list / zones / workers list` 结果可读回传;API `success:false` 时任务真失败并回传错误摘要
|
|
||||||
- **新增模块**:`cpa`(usage 备份/恢复)、`cfdav`、`mail`、`sub`(订阅管理与到期提醒)、`rate`(实时多源汇率与卡组织结算价对比)
|
|
||||||
- **QQ Bot**:AI 命令加载器、每日订阅提醒调度(09:00)
|
|
||||||
- **Web 后台**:模块设置页(CF / CPA / cfdav / AI)、OPS 任务页、渠道管理、审计页
|
|
||||||
|
|
||||||
## ✨ 功能特性
|
## ✅ 当前能力
|
||||||
|
|
||||||
- **多平台接入**:QQ 官方 Bot(WebSocket)、Telegram Bot、飞书(通道层预留)
|
### 核心功能
|
||||||
- **OPS 命令**:`/cf` `/cpa` `/cfdav` `/sub` `/mail` `/rate`,写操作带 dry-run / 确认门控
|
- 统一命令入口(Telegram / QQ / 飞书)
|
||||||
- **Runbook 自动化**:YAML 定义、步骤编排、断言校验、任务审计
|
- Runbook 执行引擎(YAML)
|
||||||
- **Web 管理后台**:模块与渠道配置、任务与审计查看
|
- 步骤级审计与回溯
|
||||||
- **优雅关闭**:SIGTERM/SIGINT 干净退出
|
- 目标主机管理(ops targets)
|
||||||
|
- 风险控制与干跑(dry-run)
|
||||||
|
|
||||||
## 📦 快速开始
|
### 已支持模块
|
||||||
|
- **CPA 模块**
|
||||||
|
- `/cpa status`
|
||||||
|
- `/cpa usage backup`
|
||||||
|
- `/cpa usage restore <backup_id> [--confirm YES_RESTORE] [--dry-run]`
|
||||||
|
|
||||||
### 二进制运行
|
- **Cloudflare 模块(CF)**
|
||||||
|
- `/cf status`
|
||||||
|
- `/cf zones`
|
||||||
|
- `/cf dns list <zone_id>`
|
||||||
|
- `/cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false]`
|
||||||
|
- `/cf dnsadd <name> <content> [on|off] [type]`
|
||||||
|
- `/cf dnsset <record_id> <content> [true]`
|
||||||
|
- `/cf dnsdel <record_id> YES`
|
||||||
|
- `/cf dnsproxy <record_id|name> on|off`
|
||||||
|
- `/cf workers list`
|
||||||
|
|
||||||
```bash
|
> 详细命令设计见:`docs/cf-commands-design.md`
|
||||||
cp config.yaml.example config.yaml
|
|
||||||
vi config.yaml # 填入 Bot Token 等
|
|
||||||
./ops-assistant
|
|
||||||
```
|
|
||||||
|
|
||||||
### Docker 运行
|
## 🧩 Runbook DSL(概览)
|
||||||
|
支持动作:
|
||||||
|
- `ssh.exec`
|
||||||
|
- `http.get` / `http.post`
|
||||||
|
- `file.upload` / `file.extract`
|
||||||
|
- `assert.json`
|
||||||
|
- `sleep`
|
||||||
|
|
||||||
```bash
|
变量来源:
|
||||||
cp config.yaml.example config.yaml && vi config.yaml
|
- `inputs.*`
|
||||||
docker-compose up -d
|
- `env.*`
|
||||||
# 或
|
- `steps.<id>.output`
|
||||||
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+
|
|
||||||
make build
|
|
||||||
make build-linux
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚙️ 配置说明
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
server:
|
|
||||||
port: 9621 # Web 后台端口
|
|
||||||
key: "your-secret-key" # 会话密钥
|
|
||||||
|
|
||||||
database:
|
|
||||||
path: "./ops.db" # SQLite 数据库路径
|
|
||||||
|
|
||||||
admin:
|
|
||||||
username: "admin" # Web 后台用户名
|
|
||||||
password: "your_password" # 弱密码启动时会告警
|
|
||||||
|
|
||||||
telegram:
|
|
||||||
enabled: true
|
|
||||||
token: "YOUR_BOT_TOKEN"
|
|
||||||
|
|
||||||
qqbot:
|
|
||||||
enabled: false
|
|
||||||
appid: "YOUR_APPID"
|
|
||||||
secret: "YOUR_SECRET"
|
|
||||||
```
|
|
||||||
|
|
||||||
> **模块凭据**(CF API Token、CPA 管理令牌、cfdav 账号等)保存在数据库 `app_settings` 表,通过 Web 后台「模块设置」页配置,**不写入配置文件**;渠道 secrets 在数据库内加密存储(`enc:v1:` 前缀)。
|
|
||||||
|
|
||||||
## 🤖 Bot 与 OPS
|
|
||||||
|
|
||||||
Bot 主要用于接收 OPS 命令并返回执行结果。支持 QQ Bot、Telegram Bot;具体渠道开关与凭据在 Web 管理后台配置。
|
|
||||||
|
|
||||||
## 🧰 OPS 命令参考
|
|
||||||
|
|
||||||
> 需先在 Web 后台启用对应模块开关(如 `enable_module_cf`)并配置凭据。查询类命令**直接回传格式化结果**;写操作回执 job 号,支持 `--dry-run` 预览。
|
|
||||||
|
|
||||||
### Cloudflare(`/cf`)
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `/cf status` | runbook 链路自检 |
|
|
||||||
| `/cf zones` | 列出账号下站点 |
|
|
||||||
| `/cf dns list <zone_id 或域名>` | DNS 记录列表(域名自动解析;🟠 = 已开橙云) |
|
|
||||||
| `/cf dns update <id> <content>` | 更新记录内容 |
|
|
||||||
| `/cf dnsadd <name> <content> [on/off] [type]` | 新增记录 |
|
|
||||||
| `/cf dnsset <id 或 name> <content>` | 按名设置记录内容 |
|
|
||||||
| `/cf dnsdel <id 或 name>` | 删除记录 |
|
|
||||||
| `/cf dnsproxy <id 或 name> on\|off` | 切换橙云/灰云 |
|
|
||||||
| `/cf workers list` | Workers 脚本列表 |
|
|
||||||
|
|
||||||
### CPA(CLIProxyAPI 管理)
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `/cpa status` | usage 快照(requests / tokens) |
|
|
||||||
| `/cpa usage backup` | 实时导出 usage 并打包备份(含 sha256/meta) |
|
|
||||||
| `/cpa usage restore <备份包>` | 恢复 usage(import 接口优先,不停服) |
|
|
||||||
|
|
||||||
### 实时汇率(`/rate`)
|
|
||||||
|
|
||||||
支持对比 Google 实时市场参考汇率与 Visa / Mastercard 官方卡组织结算汇率,并自动计算卡组织溢价与结算区间。
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `/rate` 或 `/rate help` | 查看汇率转换用法与示例 |
|
|
||||||
| `/rate <金额> <源币种> to <目标币种>` | 标准汇率转换(如 `/rate 138 sgd to cny`、`/rate 100 usd to cny`) |
|
|
||||||
| `/rate <金额><源币种> -> <目标币种>` | 箭头简写语法(如 `/rate 50 eur -> sgd`) |
|
|
||||||
| `/rate <金额><源币种>/<目标币种>` | 斜杠紧凑语法(如 `/rate 1000 php/cny`、`/rate 138sgd to cny`) |
|
|
||||||
|
|
||||||
### 其他模块
|
|
||||||
|
|
||||||
| 命令 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| `/cfdav users` | cfdav 用户列表 |
|
|
||||||
| `/cfdav add <email> <password> [admin]` | 新增用户 |
|
|
||||||
| `/cfdav del <user_id>` | 删除用户 |
|
|
||||||
| `/mail status` | 邮箱服务状态 |
|
|
||||||
| `/sub add` | 新增订阅(引导式流程) |
|
|
||||||
| `/sub list` | 订阅列表 |
|
|
||||||
| `/sub report [category\|cycle\|due <7\|30\|overdue>]` | 订阅报表 |
|
|
||||||
| `/sub del <id>` | 删除订阅 |
|
|
||||||
| `/sub cancel` | 取消进行中的新增流程 |
|
|
||||||
| `/ops modules` | 查看模块启用状态 |
|
|
||||||
|
|
||||||
## 📜 Runbook(自动化工单)
|
|
||||||
|
|
||||||
`runbooks/*.yaml` 定义自动化任务,由执行器按步骤编排运行:
|
|
||||||
|
|
||||||
- **动作**:`ssh.exec`(远程目标)、`shell.exec`(本机)、`http.get` / `http.post`、`file.upload` / `file.extract`、`assert.json`、`sleep`
|
|
||||||
- **严格断言**:`assert.json` 支持 `expect_success: true` —— API 返回 `success:false` 时任务判失败,错误码与摘要直接回传给用户(杜绝"执行成功但结果无效"的假阳性)
|
|
||||||
- **结果回传**:查询类命令的 stdout 经模块格式化后直接发回聊天窗口
|
|
||||||
- **全程审计**:每次执行落库 `ops_jobs` / `ops_job_steps`(rc、stdout/stderr tail、耗时),Web 后台「OPS 任务」页可查
|
|
||||||
|
|
||||||
示例(`runbooks/cf_dns_list.yaml`):解析 zone → API 查询 → `assert.json` 校验 `success=true` → 格式化回传。
|
|
||||||
|
|
||||||
## 🌐 Web 管理后台
|
|
||||||
|
|
||||||
访问 `http://localhost:9621`:
|
|
||||||
|
|
||||||
- 🧩 模块设置:CF / CPA / cfdav / AI
|
|
||||||
- 🔌 渠道管理:QQ / Telegram / 飞书 配置与连接测试
|
|
||||||
- 🧾 OPS 任务与目标主机管理
|
|
||||||
- 🛡️ 审计日志
|
|
||||||
|
|
||||||
## 🏗️ 项目结构
|
|
||||||
|
|
||||||
```
|
```
|
||||||
ops-assistant/
|
ops-assistant/
|
||||||
├── cmd/
|
├── cmd/ # 入口
|
||||||
│ ├── main.go # 服务入口
|
|
||||||
│ ├── ops-runner/ # CLI 直跑 runbook(运维侧入口)
|
|
||||||
│ └── runbook_test.go # runbook 联调探针(-tags runbooktest)
|
|
||||||
├── config/config.go # 配置加载与验证
|
|
||||||
├── models/models.go # 数据模型(GORM + AutoMigrate)
|
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── core/
|
│ ├── core/ # 命令解析/执行/审计/风险控制
|
||||||
│ │ ├── command/ # 命令解析
|
│ └── module/ # CPA/CF/Mail 等模块
|
||||||
│ │ ├── ecode/ # 错误码与标签
|
├── runbooks/ # YAML Runbook
|
||||||
│ │ ├── module/ # 命令模板 / 门控 / 格式化回传
|
├── templates/ # Web 控制台模板
|
||||||
│ │ ├── policy/ # feature flag / confirm / dry-run
|
├── docs/ # 设计/规范/复盘
|
||||||
│ │ ├── registry/ # 模块注册
|
└── config.yaml.example # 配置示例
|
||||||
│ │ ├── runbook/ # 执行器(ssh/shell/http/assert)
|
|
||||||
│ │ ├── ops/ # OPS 服务编排
|
|
||||||
│ │ └── ai/ # AI 命令加载
|
|
||||||
│ ├── module/ # 业务模块:cf / cpa / cfdav / mail / sub / rate
|
|
||||||
│ ├── bot/ # Telegram Bot
|
|
||||||
│ ├── qq/ # QQ Bot(WebSocket)+ 订阅提醒调度
|
|
||||||
│ ├── feishu/ # 飞书适配(预留)
|
|
||||||
│ └── web/ # Web 后台(Gin)
|
|
||||||
├── runbooks/ # 自动化 runbook(YAML)
|
|
||||||
├── templates/ # Web 前端页面
|
|
||||||
├── config.yaml.example # 配置示例
|
|
||||||
├── Dockerfile / docker-compose.yml
|
|
||||||
└── Makefile # 构建 / 版本 / 发布
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 开发与发布
|
## ⚙️ 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # 编译(自动读取 VERSION)
|
# 1. 复制配置
|
||||||
make bump-patch # 版本 +1(patch/minor/major 同理)
|
cp config.yaml.example config.yaml
|
||||||
make set-version VERSION=1.2.3
|
|
||||||
make release-check # 发布前检查(工作区干净 + 版本格式)
|
# 2. 编辑配置(Bot Token / 管理员账号等)
|
||||||
make package-release # 产出 dist/ 二进制 + sha256
|
vi config.yaml
|
||||||
make tag-release # 打 tag 并推送
|
|
||||||
make docker && make docker-push
|
# 3. 运行
|
||||||
|
./ops-assistant
|
||||||
```
|
```
|
||||||
|
|
||||||
Runbook 联调探针(不动线上服务,用数据库副本跑):
|
## 🔒 风控与审计
|
||||||
|
- 高风险操作默认关闭(feature flag)
|
||||||
|
- 需要二次确认的命令必须带确认词
|
||||||
|
- 每个命令产生 `ops_jobs` 记录,步骤落地 `ops_job_steps`
|
||||||
|
|
||||||
```bash
|
## 📌 相关文档
|
||||||
go build -tags runbooktest -o runbook_test ./cmd
|
- `docs/ops-assistant-v1.md`:v1 方案
|
||||||
./runbook_test <db_copy> <base_dir> "/cf dns list <zone_id>"
|
- `docs/cf-commands-design.md`:CF 命令设计
|
||||||
```
|
- `docs/cf-dns-runbook-incident-20260313.md`:事故复盘
|
||||||
|
- `docs/debug/cf-dnsproxy-dnsadd-20260319.md`:本次修复记录
|
||||||
## 📋 技术栈
|
|
||||||
|
|
||||||
- **语言**: Go 1.22
|
|
||||||
- **Web 框架**: Gin · **数据库**: SQLite (GORM)
|
|
||||||
**QQ SDK**: tencent-connect/botgo v0.2.1(WebSocket)· **TG SDK**: go-telegram-bot-api v5
|
|
||||||
- **自动化**: 自研 runbook 执行器(YAML 编排 + 严格断言 + 任务审计)
|
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
MIT
|
||||||
MIT
|
|
||||||
|
|||||||
+8
-5
@@ -16,6 +16,7 @@ import (
|
|||||||
"ops-assistant/internal/core/runbook"
|
"ops-assistant/internal/core/runbook"
|
||||||
"ops-assistant/internal/feishu"
|
"ops-assistant/internal/feishu"
|
||||||
"ops-assistant/internal/qq"
|
"ops-assistant/internal/qq"
|
||||||
|
"ops-assistant/internal/service"
|
||||||
"ops-assistant/internal/web"
|
"ops-assistant/internal/web"
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
"ops-assistant/version"
|
"ops-assistant/version"
|
||||||
@@ -65,6 +66,9 @@ func main() {
|
|||||||
log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err)
|
log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finance := service.NewFinanceService(db)
|
||||||
|
defer finance.Close()
|
||||||
|
|
||||||
if err := runbook.SeedDefaultTargets(db); err != nil {
|
if err := runbook.SeedDefaultTargets(db); err != nil {
|
||||||
log.Printf("⚠️ 初始化ops targets失败: %v", err)
|
log.Printf("⚠️ 初始化ops targets失败: %v", err)
|
||||||
}
|
}
|
||||||
@@ -75,7 +79,7 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if cfg.Telegram.Enabled {
|
if cfg.Telegram.Enabled {
|
||||||
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, opsSvc)
|
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance, opsSvc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -84,9 +88,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.QQBot.Enabled {
|
if cfg.QQBot.Enabled {
|
||||||
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, opsSvc)
|
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance, opsSvc)
|
||||||
go qqBot.Start(ctx)
|
go qqBot.Start(ctx)
|
||||||
go qqBot.StartSubScheduler(ctx, 9, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
@@ -100,11 +103,11 @@ func main() {
|
|||||||
return fmt.Sprintf("reload ok: tg=%v qq=%v feishu=%v", cfg.Telegram.Enabled, cfg.QQBot.Enabled, cfg.Feishu.Enabled), nil
|
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, ".", cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn)
|
webServer := web.NewWebServer(db, cfg.Database.Path, ".", finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn)
|
||||||
webServer.RegisterRoutes(engine)
|
webServer.RegisterRoutes(engine)
|
||||||
|
|
||||||
if cfg.Feishu.Enabled {
|
if cfg.Feishu.Enabled {
|
||||||
fsBot := feishu.NewBot(db, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey)
|
fsBot := feishu.NewBot(db, finance, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey)
|
||||||
fsBot.RegisterRoutes(engine)
|
fsBot.RegisterRoutes(engine)
|
||||||
go fsBot.Start(ctx)
|
go fsBot.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
55bfe12944a42957532b9f63492d9ed8ca600419c4352ffa35344a62598bc019 dist/ops-assistant-v0.0.1-linux-amd64
|
||||||
@@ -45,6 +45,9 @@ ops-assistant/
|
|||||||
- `/cpa status`
|
- `/cpa status`
|
||||||
- `/cpa usage backup`
|
- `/cpa usage backup`
|
||||||
- `/cpa usage restore <backup_id>`
|
- `/cpa usage restore <backup_id>`
|
||||||
|
- `/cpa codex clean`
|
||||||
|
- `/cpa codex test`
|
||||||
|
- `/cpa codex isolate`
|
||||||
|
|
||||||
### 约束
|
### 约束
|
||||||
- 仅允许上述命令(严格白名单)
|
- 仅允许上述命令(严格白名单)
|
||||||
|
|||||||
+146
-14
@@ -7,28 +7,31 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
xchart "ops-assistant/internal/chart"
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
|
"ops-assistant/internal/service"
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
|
|
||||||
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultUserID 统一用户ID
|
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||||
const DefaultUserID int64 = 1
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type TGBot struct {
|
type TGBot struct {
|
||||||
api *tgbotapi.BotAPI
|
api *tgbotapi.BotAPI
|
||||||
db *gorm.DB
|
finance *service.FinanceService
|
||||||
opsSvc *ops.Service
|
db *gorm.DB
|
||||||
|
opsSvc *ops.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTGBot(db *gorm.DB, token string, opsSvc *ops.Service) (*TGBot, error) {
|
func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService, opsSvc *ops.Service) (*TGBot, error) {
|
||||||
bot, err := tgbotapi.NewBotAPI(token)
|
bot, err := tgbotapi.NewBotAPI(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &TGBot{api: bot, db: db, opsSvc: opsSvc}, nil
|
return &TGBot{api: bot, finance: finance, db: db, opsSvc: opsSvc}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *TGBot) Start(ctx context.Context) {
|
func (b *TGBot) Start(ctx context.Context) {
|
||||||
@@ -88,18 +91,147 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reply := ""
|
var reply string
|
||||||
switch text {
|
|
||||||
case "/start":
|
switch {
|
||||||
reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n模块化运维命令助手,支持:\n• /cpa - CPA 管理\n• /cf - Cloudflare 管理\n• /cfdav - cfdav 管理\n• /sub - 订阅管理\n• /mail - 邮箱管理\n\n/help - 使用说明"
|
case text == "/start":
|
||||||
case "/help":
|
reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助"
|
||||||
reply = "📖 使用说明:\n\n直接发送 OPS 命令即可,例如:\n• /cpa status\n• /cf zones\n• /cf dns list <zone_id>\n• /cfdav users\n• /sub list\n\n模块入口默认关闭,可在 Web 后台「模块设置」中启用。"
|
|
||||||
|
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:
|
default:
|
||||||
reply = "❓ 无法识别,输入 /help 查看支持的命令"
|
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)
|
m := tgbotapi.NewMessage(chatID, reply)
|
||||||
if _, err := b.api.Send(m); err != nil {
|
if _, err := b.api.Send(m); err != nil {
|
||||||
log.Printf("发送消息失败 chat=%d: %v", chatID, err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -16,9 +16,6 @@ type CommandTemplate struct {
|
|||||||
MetaFn func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta
|
MetaFn func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta
|
||||||
DryRunMsg string
|
DryRunMsg string
|
||||||
SuccessMsg func(jobID uint) string
|
SuccessMsg func(jobID uint) string
|
||||||
// ResultFormatter 可选:查询类命令用最终 stdout 格式化回传内容。
|
|
||||||
// 返回 (消息, true) 表示使用格式化结果;返回 (anything, false) 回退到回执式消息。
|
|
||||||
ResultFormatter func(out string) (string, bool)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CommandSpec struct {
|
type CommandSpec struct {
|
||||||
@@ -55,14 +52,6 @@ func ExecTemplate(runner *Runner, userID int64, raw string, tpl CommandTemplate)
|
|||||||
ConfirmToken: confirmToken,
|
ConfirmToken: confirmToken,
|
||||||
}
|
}
|
||||||
jobID, out, err := runner.Run(raw, userID, req)
|
jobID, out, err := runner.Run(raw, userID, req)
|
||||||
if err != nil {
|
|
||||||
return jobID, out, err
|
|
||||||
}
|
|
||||||
if tpl.ResultFormatter != nil && out != "" && out != "dry-run" {
|
|
||||||
if msg, ok := tpl.ResultFormatter(out); ok {
|
|
||||||
return jobID, msg, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return jobID, out, err
|
return jobID, out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,8 @@ import (
|
|||||||
"ops-assistant/internal/core/registry"
|
"ops-assistant/internal/core/registry"
|
||||||
"ops-assistant/internal/core/runbook"
|
"ops-assistant/internal/core/runbook"
|
||||||
"ops-assistant/internal/module/cf"
|
"ops-assistant/internal/module/cf"
|
||||||
"ops-assistant/internal/module/cfdav"
|
|
||||||
"ops-assistant/internal/module/cpa"
|
"ops-assistant/internal/module/cpa"
|
||||||
"ops-assistant/internal/module/mail"
|
"ops-assistant/internal/module/mail"
|
||||||
"ops-assistant/internal/module/rate"
|
|
||||||
"ops-assistant/internal/module/sub"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -21,15 +18,9 @@ func BuildDefault(db *gorm.DB, dbPath, baseDir string) *Service {
|
|||||||
cpaModule := cpa.New(db, exec)
|
cpaModule := cpa.New(db, exec)
|
||||||
cfModule := cf.New(db, exec)
|
cfModule := cf.New(db, exec)
|
||||||
mailModule := mail.New(db, exec)
|
mailModule := mail.New(db, exec)
|
||||||
rateModule := rate.New()
|
|
||||||
cfdavModule := cfdav.New(db)
|
|
||||||
subModule := sub.New(db)
|
|
||||||
|
|
||||||
r.RegisterModule("cpa", cpaModule.Handle)
|
r.RegisterModule("cpa", cpaModule.Handle)
|
||||||
r.RegisterModule("cf", cfModule.Handle)
|
r.RegisterModule("cf", cfModule.Handle)
|
||||||
r.RegisterModule("mail", mailModule.Handle)
|
r.RegisterModule("mail", mailModule.Handle)
|
||||||
r.RegisterModule("rate", rateModule.Handle)
|
|
||||||
r.RegisterModule("cfdav", cfdavModule.Handle)
|
|
||||||
r.RegisterModule("sub", subModule.Handle)
|
|
||||||
return NewService(dbPath, baseDir, r)
|
return NewService(dbPath, baseDir, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func (s *Service) Handle(userID int64, text string) (bool, string) {
|
|||||||
if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" {
|
if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" {
|
||||||
return true, s.modulesStatusText()
|
return true, s.modulesStatusText()
|
||||||
}
|
}
|
||||||
if cmd.Module != "" && cmd.Module != "ops" && cmd.Module != "rate" && s.db != nil {
|
if cmd.Module != "" && cmd.Module != "ops" && s.db != nil {
|
||||||
if !coremodule.IsEnabled(s.db, cmd.Module) {
|
if !coremodule.IsEnabled(s.db, cmd.Module) {
|
||||||
return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s(开关: enable_module_%s)", cmd.Module, cmd.Module)
|
return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s(开关: enable_module_%s)", cmd.Module, cmd.Module)
|
||||||
}
|
}
|
||||||
@@ -58,15 +58,22 @@ func (s *Service) Handle(userID int64, text string) (bool, string) {
|
|||||||
|
|
||||||
func (s *Service) helpText() string {
|
func (s *Service) helpText() string {
|
||||||
lines := []string{
|
lines := []string{
|
||||||
"🛠️ OPS 帮助(模块入口):",
|
"🛠️ OPS 交互命令:",
|
||||||
"- /ops modules (查看模块启用状态)",
|
"- /ops modules (查看模块启用状态)",
|
||||||
"- /cpa (CPA 使用统计/备份/恢复)",
|
"- /cpa help",
|
||||||
"- /cf (Cloudflare 管理)",
|
"- /cpa status",
|
||||||
"- /mail (邮箱状态)",
|
"- /cpa usage backup",
|
||||||
"- /cfdav (CFDAV 用户管理)",
|
"- /cpa usage restore <backup_id> [--confirm YES_RESTORE] [--dry-run]",
|
||||||
"- /sub (订阅管理)",
|
"- /cf status (需要 enable_module_cf)",
|
||||||
"- /rate (实时汇率转换)",
|
"- /cf zones (需要 enable_module_cf)",
|
||||||
"提示:发送 /cpa /cf /mail /cfdav /sub /rate 获取各模块完整指令清单。",
|
"- /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")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
package runbook
|
package runbook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/tar"
|
|
||||||
"archive/zip"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -85,7 +80,6 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato
|
|||||||
|
|
||||||
outputs := map[string]string{}
|
outputs := map[string]string{}
|
||||||
ctx := map[string]string{}
|
ctx := map[string]string{}
|
||||||
dataOut := "ok"
|
|
||||||
|
|
||||||
jobCtx, jobCancel := context.WithCancel(context.Background())
|
jobCtx, jobCancel := context.WithCancel(context.Background())
|
||||||
registerJobCancel(job.ID, jobCancel)
|
registerJobCancel(job.ID, jobCancel)
|
||||||
@@ -180,13 +174,10 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato
|
|||||||
_ = e.db.Save(&step).Error
|
_ = e.db.Save(&step).Error
|
||||||
outputs[rendered.ID] = stdout
|
outputs[rendered.ID] = stdout
|
||||||
ctx["steps."+rendered.ID+".output"] = stdout
|
ctx["steps."+rendered.ID+".output"] = stdout
|
||||||
if strings.TrimSpace(stdout) != "" && rendered.Action != "assert.json" {
|
|
||||||
dataOut = stdout
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
e.finishJob(&job, "success", "ok")
|
e.finishJob(&job, "success", "ok")
|
||||||
return job.ID, dataOut, nil
|
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) {
|
func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) {
|
||||||
@@ -239,125 +230,6 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]
|
|||||||
}
|
}
|
||||||
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
|
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
|
||||||
|
|
||||||
case "http.get":
|
|
||||||
url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"]))
|
|
||||||
if url == "" {
|
|
||||||
return 1, "", "missing url", fmt.Errorf("missing url")
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
applyHTTPHeaders(req, st.With["headers"])
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout"))
|
|
||||||
}
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return 1, strings.TrimSpace(string(body)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return 0, strings.TrimSpace(string(body)), "", nil
|
|
||||||
|
|
||||||
case "http.post":
|
|
||||||
url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"]))
|
|
||||||
if url == "" {
|
|
||||||
return 1, "", "missing url", fmt.Errorf("missing url")
|
|
||||||
}
|
|
||||||
bodyStr := ""
|
|
||||||
if b, ok := st.With["body"]; ok {
|
|
||||||
switch t := b.(type) {
|
|
||||||
case string:
|
|
||||||
bodyStr = t
|
|
||||||
default:
|
|
||||||
if jb, err := json.Marshal(t); err == nil {
|
|
||||||
bodyStr = string(jb)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(bodyStr))
|
|
||||||
if err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
hasCT := applyHTTPHeaders(req, st.With["headers"])
|
|
||||||
if bodyStr != "" && !hasCT {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout"))
|
|
||||||
}
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
return 1, strings.TrimSpace(string(respBody)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return 0, strings.TrimSpace(string(respBody)), "", nil
|
|
||||||
|
|
||||||
case "file.upload":
|
|
||||||
source := strings.TrimSpace(fmt.Sprintf("%v", st.With["source"]))
|
|
||||||
stagingDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["staging_dir"]))
|
|
||||||
filename := strings.TrimSpace(fmt.Sprintf("%v", st.With["filename"]))
|
|
||||||
if source == "" {
|
|
||||||
return 1, "", "missing source", fmt.Errorf("missing source")
|
|
||||||
}
|
|
||||||
if stagingDir == "" {
|
|
||||||
stagingDir = "/tmp/ops-staging"
|
|
||||||
}
|
|
||||||
if filename == "" {
|
|
||||||
filename = filepath.Base(source)
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
dest := filepath.Join(stagingDir, filepath.Base(filename))
|
|
||||||
if err := copyFile(source, dest); err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
return 0, dest, "", nil
|
|
||||||
|
|
||||||
case "file.extract":
|
|
||||||
archivePath := strings.TrimSpace(fmt.Sprintf("%v", st.With["archive"]))
|
|
||||||
toDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["to_dir"]))
|
|
||||||
if archivePath == "" {
|
|
||||||
return 1, "", "missing archive", fmt.Errorf("missing archive")
|
|
||||||
}
|
|
||||||
if toDir == "" {
|
|
||||||
toDir = "/tmp/ops-staging/extracted"
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(toDir, 0o755); err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(archivePath)
|
|
||||||
switch {
|
|
||||||
case strings.HasSuffix(lower, ".zip"):
|
|
||||||
if err := extractZip(archivePath, toDir); err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"):
|
|
||||||
if err := extractTarGz(archivePath, toDir); err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
case strings.HasSuffix(lower, ".tar"):
|
|
||||||
if err := extractTar(archivePath, toDir); err != nil {
|
|
||||||
return 1, "", err.Error(), err
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 1, "", "unsupported archive format", fmt.Errorf("unsupported archive format: %s", archivePath)
|
|
||||||
}
|
|
||||||
return 0, toDir, "", nil
|
|
||||||
|
|
||||||
case "assert.json":
|
case "assert.json":
|
||||||
sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"]))
|
sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"]))
|
||||||
if sourceStep == "" {
|
if sourceStep == "" {
|
||||||
@@ -373,27 +245,15 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]
|
|||||||
return 1, "", "invalid json", err
|
return 1, "", "invalid json", err
|
||||||
}
|
}
|
||||||
|
|
||||||
expectSuccess := parseExpectSuccess(st.With["expect_success"])
|
|
||||||
if expectSuccess {
|
|
||||||
v, ok := lookupPath(payload, "success")
|
|
||||||
if !ok {
|
|
||||||
return 1, "", "json path not found: success", fmt.Errorf("json path not found: success")
|
|
||||||
}
|
|
||||||
if !truthyValue(v) {
|
|
||||||
errSummary := summarizeJSONErrors(payload)
|
|
||||||
return 1, "", "api success=false: " + errSummary, fmt.Errorf("api success=false: %s", errSummary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rules := parseRequiredPaths(st.With["required_paths"])
|
rules := parseRequiredPaths(st.With["required_paths"])
|
||||||
|
if len(rules) == 0 {
|
||||||
|
return 1, "", "required_paths empty", fmt.Errorf("required_paths empty")
|
||||||
|
}
|
||||||
for _, p := range rules {
|
for _, p := range rules {
|
||||||
if _, ok := lookupPath(payload, p); !ok {
|
if _, ok := lookupPath(payload, p); !ok {
|
||||||
return 1, "", "json path not found: " + p, fmt.Errorf("json path not found: %s", p)
|
return 1, "", "json path not found: " + p, fmt.Errorf("json path not found: %s", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(rules) == 0 && !expectSuccess {
|
|
||||||
return 1, "", "required_paths empty", fmt.Errorf("required_paths empty")
|
|
||||||
}
|
|
||||||
return 0, "assert ok", "", nil
|
return 0, "assert ok", "", nil
|
||||||
|
|
||||||
case "sleep":
|
case "sleep":
|
||||||
@@ -432,33 +292,27 @@ func renderStep(st Step, ctx map[string]string) Step {
|
|||||||
}
|
}
|
||||||
m := make(map[string]any, len(out.With))
|
m := make(map[string]any, len(out.With))
|
||||||
for k, v := range out.With {
|
for k, v := range out.With {
|
||||||
m[k] = renderAny(v, ctx)
|
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
|
out.With = m
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderAny(v any, ctx map[string]string) any {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case string:
|
|
||||||
return renderString(t, ctx)
|
|
||||||
case []any:
|
|
||||||
arr := make([]any, 0, len(t))
|
|
||||||
for _, it := range t {
|
|
||||||
arr = append(arr, renderAny(it, ctx))
|
|
||||||
}
|
|
||||||
return arr
|
|
||||||
case map[string]any:
|
|
||||||
m := make(map[string]any, len(t))
|
|
||||||
for k, v := range t {
|
|
||||||
m[k] = renderAny(v, ctx)
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
default:
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderString(s string, ctx map[string]string) string {
|
func renderString(s string, ctx map[string]string) string {
|
||||||
res := s
|
res := s
|
||||||
for k, v := range ctx {
|
for k, v := range ctx {
|
||||||
@@ -467,158 +321,6 @@ func renderString(s string, ctx map[string]string) string {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyHTTPHeaders(req *http.Request, raw any) bool {
|
|
||||||
hasContentType := false
|
|
||||||
m, ok := raw.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return hasContentType
|
|
||||||
}
|
|
||||||
for k, v := range m {
|
|
||||||
key := strings.TrimSpace(k)
|
|
||||||
if key == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val := strings.TrimSpace(fmt.Sprintf("%v", v))
|
|
||||||
if val == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
req.Header.Set(key, val)
|
|
||||||
if strings.EqualFold(key, "Content-Type") {
|
|
||||||
hasContentType = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hasContentType
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyFile(src, dst string) error {
|
|
||||||
in, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer in.Close()
|
|
||||||
fi, err := in.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !fi.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("source is not regular file")
|
|
||||||
}
|
|
||||||
out, err := os.Create(dst)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
if _, err := io.Copy(out, in); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return out.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractZip(src, dst string) error {
|
|
||||||
zr, err := zip.OpenReader(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer zr.Close()
|
|
||||||
for _, f := range zr.File {
|
|
||||||
clean := filepath.Clean(f.Name)
|
|
||||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
|
||||||
return fmt.Errorf("invalid zip entry path: %s", f.Name)
|
|
||||||
}
|
|
||||||
target := filepath.Join(dst, clean)
|
|
||||||
if f.FileInfo().IsDir() {
|
|
||||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
rc, err := f.Open()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out, err := os.Create(target)
|
|
||||||
if err != nil {
|
|
||||||
rc.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, cpErr := io.Copy(out, rc)
|
|
||||||
rc.Close()
|
|
||||||
if err := out.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cpErr != nil {
|
|
||||||
return cpErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractTar(src, dst string) error {
|
|
||||||
f, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
return untar(f, dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractTarGz(src, dst string) error {
|
|
||||||
f, err := os.Open(src)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
gz, err := gzip.NewReader(f)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer gz.Close()
|
|
||||||
return untar(gz, dst)
|
|
||||||
}
|
|
||||||
|
|
||||||
func untar(r io.Reader, dst string) error {
|
|
||||||
tr := tar.NewReader(r)
|
|
||||||
for {
|
|
||||||
hdr, err := tr.Next()
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
clean := filepath.Clean(hdr.Name)
|
|
||||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
|
||||||
return fmt.Errorf("invalid tar entry path: %s", hdr.Name)
|
|
||||||
}
|
|
||||||
target := filepath.Join(dst, clean)
|
|
||||||
switch hdr.Typeflag {
|
|
||||||
case tar.TypeDir:
|
|
||||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
case tar.TypeReg, tar.TypeRegA:
|
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
out, err := os.Create(target)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := io.Copy(out, tr); err != nil {
|
|
||||||
out.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := out.Close(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseRequiredPaths(v any) []string {
|
func parseRequiredPaths(v any) []string {
|
||||||
res := []string{}
|
res := []string{}
|
||||||
switch t := v.(type) {
|
switch t := v.(type) {
|
||||||
@@ -661,51 +363,6 @@ func lookupPath(root any, path string) (any, bool) {
|
|||||||
return cur, true
|
return cur, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseExpectSuccess(v any) bool {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case bool:
|
|
||||||
return t
|
|
||||||
case string:
|
|
||||||
return strings.EqualFold(strings.TrimSpace(t), "true")
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func truthyValue(v any) bool {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case bool:
|
|
||||||
return t
|
|
||||||
case string:
|
|
||||||
return strings.EqualFold(strings.TrimSpace(t), "true")
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// summarizeJSONErrors 从 Cloudflare 风格的 {"success":false,"errors":[...]} 中提取可读错误摘要
|
|
||||||
func summarizeJSONErrors(payload any) string {
|
|
||||||
msgs := []string{}
|
|
||||||
if m, ok := payload.(map[string]any); ok {
|
|
||||||
if errs, ok := m["errors"].([]any); ok {
|
|
||||||
for _, e := range errs {
|
|
||||||
switch ev := e.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
code := fmt.Sprintf("%v", ev["code"])
|
|
||||||
msg := fmt.Sprintf("%v", ev["message"])
|
|
||||||
msgs = append(msgs, code+" "+msg)
|
|
||||||
default:
|
|
||||||
msgs = append(msgs, fmt.Sprintf("%v", ev))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(msgs) == 0 {
|
|
||||||
return "unknown error"
|
|
||||||
}
|
|
||||||
return strings.Join(msgs, "; ")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Executor) finishJob(job *models.OpsJob, status, summary string) {
|
func (e *Executor) finishJob(job *models.OpsJob, status, summary string) {
|
||||||
job.Status = status
|
job.Status = status
|
||||||
job.Summary = summary
|
job.Summary = summary
|
||||||
|
|||||||
@@ -35,8 +35,3 @@ func resolveTarget(db *gorm.DB, name string) ResolvedTarget {
|
|||||||
}
|
}
|
||||||
return ResolvedTarget{Found: true, User: user, Host: host, Port: port}
|
return ResolvedTarget{Found: true, User: user, Host: host, Port: port}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveTargetForModule 供其他包(如 cf 模块格式化器)复用目标解析
|
|
||||||
func ResolveTargetForModule(db *gorm.DB, name string) ResolvedTarget {
|
|
||||||
return resolveTarget(db, name)
|
|
||||||
}
|
|
||||||
|
|||||||
+37
-11
@@ -2,24 +2,28 @@ package feishu
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ops-assistant/internal/channel"
|
"ops-assistant/internal/channel"
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
|
"ops-assistant/internal/service"
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultUserID 统一用户ID
|
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||||
const DefaultUserID int64 = 1
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
finance *service.FinanceService
|
||||||
opsSvc *ops.Service
|
opsSvc *ops.Service
|
||||||
appID string
|
appID string
|
||||||
appSecret string
|
appSecret string
|
||||||
@@ -27,9 +31,10 @@ type Bot struct {
|
|||||||
encryptKey string
|
encryptKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(db *gorm.DB, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot {
|
func NewBot(db *gorm.DB, finance *service.FinanceService, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot {
|
||||||
return &Bot{
|
return &Bot{
|
||||||
db: db,
|
db: db,
|
||||||
|
finance: finance,
|
||||||
opsSvc: opsSvc,
|
opsSvc: opsSvc,
|
||||||
appID: appID,
|
appID: appID,
|
||||||
appSecret: appSecret,
|
appSecret: appSecret,
|
||||||
@@ -51,29 +56,29 @@ func (b *Bot) RegisterRoutes(r *gin.Engine) {
|
|||||||
func (b *Bot) handleWebhook(c *gin.Context) {
|
func (b *Bot) handleWebhook(c *gin.Context) {
|
||||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{"error": "bad request"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "bad request"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一走 channel 包解析,便于后续扩展验签/解密
|
// 统一走 channel 包解析,便于后续扩展验签/解密
|
||||||
msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken)
|
msg, verifyChallenge, err := channel.ParseFeishuInbound(body, b.verificationToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if verifyChallenge != "" {
|
if verifyChallenge != "" {
|
||||||
c.JSON(200, gin.H{"challenge": verifyChallenge})
|
c.JSON(http.StatusOK, gin.H{"challenge": verifyChallenge})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msg == nil {
|
if msg == nil {
|
||||||
c.JSON(200, gin.H{"code": 0})
|
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 幂等去重
|
// 幂等去重
|
||||||
var existed models.MessageDedup
|
var existed models.MessageDedup
|
||||||
if err := b.db.Where("platform = ? AND event_id = ?", "feishu", msg.EventID).First(&existed).Error; err == nil {
|
if err := b.db.Where("platform = ? AND event_id = ?", "feishu", msg.EventID).First(&existed).Error; err == nil {
|
||||||
c.JSON(200, gin.H{"code": 0})
|
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error
|
_ = b.db.Create(&models.MessageDedup{Platform: "feishu", EventID: msg.EventID, ProcessedAt: time.Now()}).Error
|
||||||
@@ -86,7 +91,7 @@ func (b *Bot) handleWebhook(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{"code": 0})
|
c.JSON(http.StatusOK, gin.H{"code": 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) handleText(text string) string {
|
func (b *Bot) handleText(text string) string {
|
||||||
@@ -98,10 +103,31 @@ func (b *Bot) handleText(text string) string {
|
|||||||
}
|
}
|
||||||
switch trim {
|
switch trim {
|
||||||
case "帮助", "help", "/help", "菜单", "功能", "/start":
|
case "帮助", "help", "/help", "菜单", "功能", "/start":
|
||||||
return "🛠️ Ops-Assistant\n\n模块化运维命令助手,支持:\n• /cpa - CPA 管理\n• /cf - Cloudflare 管理\n• /cfdav - cfdav 管理\n• /sub - 订阅管理\n• /mail - 邮箱管理"
|
return "🛠️ Ops-Assistant\n\n直接发送消费描述即可记账:\n• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计"
|
||||||
default:
|
case "查看", "记录", "列表", "最近":
|
||||||
return "❓ 无法识别,请使用 OPS 命令(发送「帮助」查看说明)。"
|
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 {
|
func maskID(s string) string {
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ func commandSpecs() []coremodule.CommandSpec {
|
|||||||
},
|
},
|
||||||
DryRunMsg: "🧪 dry-run: 将执行 /cf zones(未实际执行)",
|
DryRunMsg: "🧪 dry-run: 将执行 /cf zones(未实际执行)",
|
||||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf zones 已执行,job=%d", jobID) },
|
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf zones 已执行,job=%d", jobID) },
|
||||||
ResultFormatter: func(out string) (string, bool) { return FormatZones(out) },
|
|
||||||
},
|
},
|
||||||
ErrPrefix: "/cf zones 执行失败: ",
|
ErrPrefix: "/cf zones 执行失败: ",
|
||||||
},
|
},
|
||||||
@@ -45,19 +44,7 @@ func commandSpecs() []coremodule.CommandSpec {
|
|||||||
if len(parts) < 4 {
|
if len(parts) < 4 {
|
||||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns list <zone_id>"))
|
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "参数不足,示例:/cf dns list <zone_id>"))
|
||||||
}
|
}
|
||||||
zoneID := parts[3]
|
return map[string]string{"zone_id": parts[3]}, nil
|
||||||
if !isHex32(zoneID) {
|
|
||||||
acct, tok := cfSettings()
|
|
||||||
if acct == "" || tok == "" {
|
|
||||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "cf 凭据未配置,无法解析域名;请直接传 zone_id"))
|
|
||||||
}
|
|
||||||
resolved, _, err := ResolveZoneIDIfDomain(zoneID, acct, tok)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, err.Error()))
|
|
||||||
}
|
|
||||||
zoneID = resolved
|
|
||||||
}
|
|
||||||
return map[string]string{"zone_id": zoneID}, nil
|
|
||||||
},
|
},
|
||||||
Gate: coremodule.Gate{
|
Gate: coremodule.Gate{
|
||||||
NeedFlag: "enable_module_cf",
|
NeedFlag: "enable_module_cf",
|
||||||
@@ -65,7 +52,6 @@ func commandSpecs() []coremodule.CommandSpec {
|
|||||||
},
|
},
|
||||||
DryRunMsg: "🧪 dry-run: 将执行 /cf dns list <zone_id>(未实际执行)",
|
DryRunMsg: "🧪 dry-run: 将执行 /cf dns list <zone_id>(未实际执行)",
|
||||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行,job=%d", jobID) },
|
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf dns list 已执行,job=%d", jobID) },
|
||||||
ResultFormatter: func(out string) (string, bool) { return FormatDNSList(out) },
|
|
||||||
},
|
},
|
||||||
ErrPrefix: "/cf dns list 执行失败: ",
|
ErrPrefix: "/cf dns list 执行失败: ",
|
||||||
ErrHint: "/cf dns list <zone_id>",
|
ErrHint: "/cf dns list <zone_id>",
|
||||||
@@ -261,7 +247,6 @@ func commandSpecs() []coremodule.CommandSpec {
|
|||||||
},
|
},
|
||||||
DryRunMsg: "🧪 dry-run: 将执行 /cf workers list(未实际执行)",
|
DryRunMsg: "🧪 dry-run: 将执行 /cf workers list(未实际执行)",
|
||||||
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf workers list 已执行,job=%d", jobID) },
|
SuccessMsg: func(jobID uint) string { return fmt.Sprintf("✅ /cf workers list 已执行,job=%d", jobID) },
|
||||||
ResultFormatter: func(out string) (string, bool) { return FormatWorkers(out) },
|
|
||||||
},
|
},
|
||||||
ErrPrefix: "/cf workers list 执行失败: ",
|
ErrPrefix: "/cf workers list 执行失败: ",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
package cf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"ops-assistant/internal/core/runbook"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ModuleRef 由 module.go 注入 db 句柄,避免循环依赖
|
|
||||||
var ModuleRef struct {
|
|
||||||
DB *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// cfSettings 从 app_settings 读取 CF 凭据(与 executor 相同存储约定)
|
|
||||||
func cfSettings() (accountID, apiToken string) {
|
|
||||||
if ModuleRef.DB == nil {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
type setting struct {
|
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
var rows []setting
|
|
||||||
if err := ModuleRef.DB.Table("app_settings").Where("key IN ?", []string{"cf_account_id", "cf_api_token"}).Find(&rows).Error; err != nil {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
|
||||||
switch r.Key {
|
|
||||||
case "cf_account_id":
|
|
||||||
accountID = r.Value
|
|
||||||
case "cf_api_token":
|
|
||||||
apiToken = r.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return accountID, apiToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolveZoneIDIfDomain 判断 zoneIDOrName 是否为域名(非 32 位十六进制),
|
|
||||||
// 若是则调用 zones runbook 的同款 API 逻辑解析为 zone_id。
|
|
||||||
// 返回 (解析后的 zone_id, 是否发生了替换, error)。
|
|
||||||
func ResolveZoneIDIfDomain(zoneIDOrName, accountID, apiToken string) (string, bool, error) {
|
|
||||||
s := strings.TrimSpace(zoneIDOrName)
|
|
||||||
if s == "" || isHex32(s) {
|
|
||||||
return s, false, nil
|
|
||||||
}
|
|
||||||
zones, err := fetchZones(accountID, apiToken)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, err
|
|
||||||
}
|
|
||||||
for _, z := range zones {
|
|
||||||
if strings.EqualFold(z.Name, s) {
|
|
||||||
return z.ID, true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", false, fmt.Errorf("找不到域名 %s 对应的 zone(账号下无此域名或无权限)", s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isHex32(s string) bool {
|
|
||||||
if len(s) != 32 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, c := range s {
|
|
||||||
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
type cfZone struct {
|
|
||||||
ID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchZones(accountID, apiToken string) ([]cfZone, error) {
|
|
||||||
// 与 runbooks/cf_zones.yaml 相同的 API 调用方式
|
|
||||||
url := "https://api.cloudflare.com/client/v4/zones?account.id=" + accountID + "&per_page=200"
|
|
||||||
body, err := cfHTTPGet(url, apiToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var data struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Result []struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
} `json:"result"`
|
|
||||||
Errors []json.RawMessage `json:"errors"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(body), &data); err != nil {
|
|
||||||
return nil, fmt.Errorf("zones 响应解析失败: %v", err)
|
|
||||||
}
|
|
||||||
if !data.Success {
|
|
||||||
return nil, fmt.Errorf("zones API 返回 success=false")
|
|
||||||
}
|
|
||||||
out := make([]cfZone, 0, len(data.Result))
|
|
||||||
for _, z := range data.Result {
|
|
||||||
out = append(out, cfZone{ID: z.ID, Name: z.Name})
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func cfHTTPGet(url, apiToken string) (string, error) {
|
|
||||||
// 通过 SSH 在 hwsg 上用 python3+requests 发请求(与 runbook 网络环境一致)
|
|
||||||
script := fmt.Sprintf(
|
|
||||||
"python3 -c \"import os,requests; token=os.environ['CF_TOKEN']; resp=requests.get(os.environ['CF_URL'], headers={'Authorization':'Bearer '+token}, timeout=15); print(resp.text)\"",
|
|
||||||
)
|
|
||||||
out, err := sshExec(script, map[string]string{"CF_TOKEN": apiToken, "CF_URL": url})
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sshExec(script string, env map[string]string) (string, error) {
|
|
||||||
if ModuleRef.DB == nil {
|
|
||||||
return "", fmt.Errorf("db 未初始化,无法解析目标主机")
|
|
||||||
}
|
|
||||||
// 解析 hwsg 目标(与 runbook targets 相同约定)
|
|
||||||
resolved := runbook.ResolveTargetForModule(ModuleRef.DB, "hwsg")
|
|
||||||
if !resolved.Found {
|
|
||||||
return "", fmt.Errorf("未找到目标主机 hwsg")
|
|
||||||
}
|
|
||||||
envStr := ""
|
|
||||||
for k, v := range env {
|
|
||||||
envStr += fmt.Sprintf("%s=%q ", k, v)
|
|
||||||
}
|
|
||||||
args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, envStr + script}
|
|
||||||
cmd := exec.Command("ssh", args...)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 以下为格式化函数(ResultFormatter 用)
|
|
||||||
|
|
||||||
type dnsRecord struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Proxied bool `json:"proxied"`
|
|
||||||
TTL int `json:"ttl"`
|
|
||||||
Comment string `json:"comment"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatDNSList 把 cf_dns_list 的 stdout(CF API 原始 JSON)格式化为可读消息
|
|
||||||
func FormatDNSList(out string) (string, bool) {
|
|
||||||
var data struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Result []dnsRecord
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if !data.Success {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
recs := data.Result
|
|
||||||
// 排序:类型 -> 名称
|
|
||||||
sort.Slice(recs, func(i, j int) bool {
|
|
||||||
if recs[i].Type != recs[j].Type {
|
|
||||||
return recs[i].Type < recs[j].Type
|
|
||||||
}
|
|
||||||
return recs[i].Name < recs[j].Name
|
|
||||||
})
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("📋 DNS 记录(共 %d 条):\n", len(recs)))
|
|
||||||
for _, r := range recs {
|
|
||||||
proxyMark := ""
|
|
||||||
if r.Proxied {
|
|
||||||
proxyMark = " 🟠"
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("• %s %s → %s%s\n", r.Type, r.Name, r.Content, proxyMark))
|
|
||||||
}
|
|
||||||
return strings.TrimRight(sb.String(), "\n"), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatZones 把 cf_zones 的 stdout 格式化为可读消息
|
|
||||||
func FormatZones(out string) (string, bool) {
|
|
||||||
var data struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Zones []struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
} `json:"zones"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if !data.Success {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("🌐 Zones(共 %d 个):\n", len(data.Zones)))
|
|
||||||
for _, z := range data.Zones {
|
|
||||||
sb.WriteString(fmt.Sprintf("• %s\n %s\n", z.Name, z.ID))
|
|
||||||
}
|
|
||||||
return strings.TrimRight(sb.String(), "\n"), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatWorkers 把 cf_workers_list 的 stdout 格式化为可读消息
|
|
||||||
func FormatWorkers(out string) (string, bool) {
|
|
||||||
var data struct {
|
|
||||||
Workers []string `json:"workers"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &data); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if data.Workers == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("⚙️ Workers(共 %d 个):\n", len(data.Workers)))
|
|
||||||
for _, w := range data.Workers {
|
|
||||||
sb.WriteString("• " + w + "\n")
|
|
||||||
}
|
|
||||||
return strings.TrimRight(sb.String(), "\n"), true
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,6 @@ type Module struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(db *gorm.DB, exec *runbook.Executor) *Module {
|
func New(db *gorm.DB, exec *runbook.Executor) *Module {
|
||||||
ModuleRef.DB = db
|
|
||||||
return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)}
|
return &Module{db: db, exec: exec, runner: coremodule.NewRunner(db, exec)}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,10 +34,6 @@ func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error
|
|||||||
if out == "dry-run" {
|
if out == "dry-run" {
|
||||||
return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil
|
return ecode.Tag("OK", coremodule.FormatDryRunMessage(sp.Template)), nil
|
||||||
}
|
}
|
||||||
// 查询类命令:结果已由 ResultFormatter 格式化;写操作回退到回执消息
|
|
||||||
if out != "" && out != "dry-run" && len(out) > 2 {
|
|
||||||
return ecode.Tag("OK", out), nil
|
|
||||||
}
|
|
||||||
return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil
|
return ecode.Tag("OK", coremodule.FormatSuccessMessage(sp.Template, jobID)), nil
|
||||||
}
|
}
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "CF 模块已接入,当前支持:/cf status, /cf help"), nil
|
return ecode.Tag(ecode.ErrStepFailed, "CF 模块已接入,当前支持:/cf status, /cf help"), nil
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
package cfdav
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ops-assistant/internal/core/command"
|
|
||||||
"ops-assistant/internal/core/ecode"
|
|
||||||
"ops-assistant/models"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Module struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type settings struct {
|
|
||||||
APIBase string
|
|
||||||
AdminUser string
|
|
||||||
AdminPass string
|
|
||||||
}
|
|
||||||
|
|
||||||
type userItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
IsAdmin int `json:"is_admin"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type listResp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data []userItem `json:"data"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type simpleResp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db *gorm.DB) *Module {
|
|
||||||
return &Module{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) {
|
|
||||||
text := strings.TrimSpace(cmd.Raw)
|
|
||||||
if text == "/cfdav" || strings.HasPrefix(text, "/cfdav help") {
|
|
||||||
return "cfdav 模块\n- /cfdav users\n- /cfdav add <email> <password> [admin]\n- /cfdav del <user_id>", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Fields(text)
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "空命令"), nil
|
|
||||||
}
|
|
||||||
// 支持别名:/cfdav list
|
|
||||||
if len(parts) >= 2 && (parts[1] == "users" || parts[1] == "list") {
|
|
||||||
return m.listUsers()
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 && (parts[1] == "add" || (len(parts) >= 3 && parts[1] == "user" && parts[2] == "add")) {
|
|
||||||
return m.addUser(parts)
|
|
||||||
}
|
|
||||||
if len(parts) >= 2 && (parts[1] == "del" || parts[1] == "delete" || (len(parts) >= 3 && parts[1] == "user" && (parts[2] == "del" || parts[2] == "delete"))) {
|
|
||||||
return m.deleteUser(parts)
|
|
||||||
}
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "cfdav 模块已接入,当前支持:/cfdav users, /cfdav add, /cfdav del"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) listUsers() (string, error) {
|
|
||||||
cfg, err := m.loadSettings()
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users"
|
|
||||||
resp, status, err := m.doRequest(http.MethodGet, url, nil, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil
|
|
||||||
}
|
|
||||||
var out listResp
|
|
||||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
|
|
||||||
}
|
|
||||||
if !out.Success {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
|
|
||||||
}
|
|
||||||
if len(out.Data) == 0 {
|
|
||||||
return "📭 cfdav 用户列表为空", nil
|
|
||||||
}
|
|
||||||
limit := len(out.Data)
|
|
||||||
if limit > 50 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
lines := make([]string, 0, limit+2)
|
|
||||||
lines = append(lines, fmt.Sprintf("✅ cfdav 用户列表(%d)", len(out.Data)))
|
|
||||||
for i := 0; i < limit; i++ {
|
|
||||||
u := out.Data[i]
|
|
||||||
admin := "user"
|
|
||||||
if u.IsAdmin == 1 {
|
|
||||||
admin = "admin"
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("- %s | %s | %s", u.ID, u.Email, admin))
|
|
||||||
}
|
|
||||||
if len(out.Data) > limit {
|
|
||||||
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(out.Data), limit))
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) addUser(parts []string) (string, error) {
|
|
||||||
cfg, err := m.loadSettings()
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
// 支持: /cfdav add <email> <pass> [admin]
|
|
||||||
// 或: /cfdav user add <email> <pass> [admin]
|
|
||||||
idx := 2
|
|
||||||
if len(parts) >= 3 && parts[1] == "user" {
|
|
||||||
idx = 3
|
|
||||||
}
|
|
||||||
if len(parts) < idx+2 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav add <email> <password> [admin]"), nil
|
|
||||||
}
|
|
||||||
email := strings.TrimSpace(parts[idx])
|
|
||||||
pass := strings.TrimSpace(parts[idx+1])
|
|
||||||
if email == "" || pass == "" {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "email/password 不能为空"), nil
|
|
||||||
}
|
|
||||||
isAdmin := false
|
|
||||||
if len(parts) >= idx+3 {
|
|
||||||
flag := strings.ToLower(strings.TrimSpace(parts[idx+2]))
|
|
||||||
isAdmin = flag == "admin" || flag == "1" || flag == "true" || flag == "yes"
|
|
||||||
}
|
|
||||||
payload := map[string]any{"email": email, "password": pass, "isAdmin": isAdmin}
|
|
||||||
body, _ := json.Marshal(payload)
|
|
||||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users"
|
|
||||||
resp, status, err := m.doRequest(http.MethodPost, url, body, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil
|
|
||||||
}
|
|
||||||
var out simpleResp
|
|
||||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
|
|
||||||
}
|
|
||||||
if !out.Success {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("✅ cfdav 用户已创建:%s", email), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) deleteUser(parts []string) (string, error) {
|
|
||||||
cfg, err := m.loadSettings()
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
// 支持: /cfdav del <id> 或 /cfdav user del <id>
|
|
||||||
idx := 2
|
|
||||||
if len(parts) >= 3 && parts[1] == "user" {
|
|
||||||
idx = 3
|
|
||||||
}
|
|
||||||
if len(parts) < idx+1 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "缺少参数,示例:/cfdav del <user_id>"), nil
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(parts[idx])
|
|
||||||
if id == "" {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "user_id 不能为空"), nil
|
|
||||||
}
|
|
||||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/admin/users/" + id
|
|
||||||
resp, status, err := m.doRequest(http.MethodDelete, url, nil, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, resp)), nil
|
|
||||||
}
|
|
||||||
var out simpleResp
|
|
||||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
|
|
||||||
}
|
|
||||||
if !out.Success {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("✅ cfdav 用户已删除:%s", id), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) loadSettings() (*settings, error) {
|
|
||||||
if m.db == nil {
|
|
||||||
return nil, errors.New("db 未初始化")
|
|
||||||
}
|
|
||||||
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
|
|
||||||
cfg := &settings{}
|
|
||||||
for _, k := range keys {
|
|
||||||
var sset models.AppSetting
|
|
||||||
val := ""
|
|
||||||
if err := m.db.Where("key = ?", k).First(&sset).Error; err == nil {
|
|
||||||
val = sset.Value
|
|
||||||
}
|
|
||||||
switch k {
|
|
||||||
case "cfdav_api_base":
|
|
||||||
cfg.APIBase = strings.TrimSpace(val)
|
|
||||||
case "cfdav_admin_user":
|
|
||||||
cfg.AdminUser = strings.TrimSpace(val)
|
|
||||||
case "cfdav_admin_pass":
|
|
||||||
cfg.AdminPass = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.APIBase == "" {
|
|
||||||
return nil, errors.New("cfdav api_base 未配置")
|
|
||||||
}
|
|
||||||
if cfg.AdminUser == "" || cfg.AdminPass == "" {
|
|
||||||
return nil, errors.New("cfdav admin 账号未配置")
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) doRequest(method, url string, body []byte, cfg *settings) (string, int, error) {
|
|
||||||
var reader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
reader = strings.NewReader(string(body))
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(method, url, reader)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
auth := cfg.AdminUser + ":" + cfg.AdminPass
|
|
||||||
b64 := base64.StdEncoding.EncodeToString([]byte(auth))
|
|
||||||
req.Header.Set("Authorization", "Basic "+b64)
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", resp.StatusCode, err
|
|
||||||
}
|
|
||||||
return string(data), resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package rate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"ops-assistant/internal/core/command"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Module struct{}
|
|
||||||
|
|
||||||
func New() *Module { return &Module{} }
|
|
||||||
|
|
||||||
var re = regexp.MustCompile(`(?i)^/rate\s+([0-9]+(?:\.[0-9]+)?)\s*([a-z]{3})\s*(?:(?:to)\s*|->\s*|/\s*)([a-z]{3})\s*$`)
|
|
||||||
|
|
||||||
func (m *Module) Handle(_ int64, cmd *command.ParsedCommand) (string, error) {
|
|
||||||
text := strings.TrimSpace(cmd.Raw)
|
|
||||||
if strings.EqualFold(text, "/rate") || strings.HasPrefix(strings.ToLower(text), "/rate help") {
|
|
||||||
return help(), nil
|
|
||||||
}
|
|
||||||
hit := re.FindStringSubmatch(text)
|
|
||||||
if hit == nil {
|
|
||||||
return help(), nil
|
|
||||||
}
|
|
||||||
amount, err := strconv.ParseFloat(hit[1], 64)
|
|
||||||
if err != nil || amount <= 0 {
|
|
||||||
return "❌ 金额无效", nil
|
|
||||||
}
|
|
||||||
base := strings.ToUpper(hit[2])
|
|
||||||
quote := strings.ToUpper(hit[3])
|
|
||||||
if base == quote {
|
|
||||||
return fmt.Sprintf("💱 %s → %s\n\n%s %s = %s %s", base, quote, hit[1], base, hit[1], quote), nil
|
|
||||||
}
|
|
||||||
return query(amount, base, quote)
|
|
||||||
}
|
|
||||||
|
|
||||||
func help() string {
|
|
||||||
return "💱 汇率转换\n\n用法:/rate 138 sgd to cny\n\n也支持:\n/rate 138sgd to cny\n/rate 100 usd to cny\n/rate 50 eur -> sgd\n/rate 1000 php/cny\n\n数据源:Google Finance、Visa、Mastercard。Visa/万事达为官方结算汇率;Google 为市场参考价。"
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package rate
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func query(amount float64, base, quote string) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 190*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
cmd := exec.CommandContext(ctx, "python3", "scripts/rate_query.py", strconv.FormatFloat(amount, 'f', -1, 64), base, quote)
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if ctx.Err() == context.DeadlineExceeded {
|
|
||||||
return "", fmt.Errorf("汇率服务超时,请稍后重试")
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("汇率查询失败")
|
|
||||||
}
|
|
||||||
text := strings.TrimSpace(string(out))
|
|
||||||
if text == "" {
|
|
||||||
return "", fmt.Errorf("未获取到汇率")
|
|
||||||
}
|
|
||||||
return text, nil
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
package sub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ops-assistant/internal/core/command"
|
|
||||||
"ops-assistant/internal/core/ecode"
|
|
||||||
"ops-assistant/models"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Module struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type settings struct {
|
|
||||||
APIBase string
|
|
||||||
Token string
|
|
||||||
}
|
|
||||||
|
|
||||||
type subItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
Account string `json:"account"`
|
|
||||||
Amount float64 `json:"amount"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
DueDate string `json:"due_date"`
|
|
||||||
Cycle string `json:"cycle"`
|
|
||||||
CycleUnit string `json:"cycle_unit"`
|
|
||||||
CycleValue int `json:"cycle_value"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
UserID string `json:"user_id"`
|
|
||||||
Channel string `json:"channel"`
|
|
||||||
Remind []int `json:"remind_days"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type listResp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data []subItem `json:"data"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type simpleResp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(db *gorm.DB) *Module {
|
|
||||||
return &Module{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) Handle(userID int64, cmd *command.ParsedCommand) (string, error) {
|
|
||||||
text := strings.TrimSpace(cmd.Raw)
|
|
||||||
if text == "/sub" || strings.HasPrefix(text, "/sub help") {
|
|
||||||
return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del <id>\n- /sub cancel(或 /cancel)取消新增流程", nil
|
|
||||||
}
|
|
||||||
parts := strings.Fields(text)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return "❌ 用法:/sub add | /sub list | /sub del <id>", nil
|
|
||||||
}
|
|
||||||
switch parts[1] {
|
|
||||||
case "add":
|
|
||||||
return "🧩 进入订阅新增流程:名称→分类→账号(可选)→金额→币种→到期日→周期单位→周期数量", nil
|
|
||||||
case "list":
|
|
||||||
return m.list(userID)
|
|
||||||
case "report":
|
|
||||||
return m.report(userID, parts[2:])
|
|
||||||
case "del", "delete":
|
|
||||||
if len(parts) < 3 {
|
|
||||||
return "❌ 用法:/sub del <id>", nil
|
|
||||||
}
|
|
||||||
return m.del(userID, strings.TrimSpace(parts[2]))
|
|
||||||
default:
|
|
||||||
return "❌ 用法:/sub add | /sub list | /sub del <id>", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatCycleText(unit string, value int, legacy string) string {
|
|
||||||
u := strings.TrimSpace(unit)
|
|
||||||
if u == "" {
|
|
||||||
if strings.TrimSpace(legacy) != "" {
|
|
||||||
return legacy
|
|
||||||
}
|
|
||||||
return "-"
|
|
||||||
}
|
|
||||||
if value <= 0 {
|
|
||||||
value = 1
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s x%d", u, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) list(userID int64) (string, error) {
|
|
||||||
cfg, err := m.loadSettings()
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions?user_id=1&channel=qq"
|
|
||||||
body, status, err := m.doRequest(http.MethodGet, url, nil, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, body)), nil
|
|
||||||
}
|
|
||||||
var out listResp
|
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
|
|
||||||
}
|
|
||||||
if !out.Success {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
|
|
||||||
}
|
|
||||||
if len(out.Data) == 0 {
|
|
||||||
return "📭 订阅列表为空", nil
|
|
||||||
}
|
|
||||||
lines := make([]string, 0, len(out.Data)+2)
|
|
||||||
lines = append(lines, fmt.Sprintf("✅ 订阅列表(%d)", len(out.Data)))
|
|
||||||
limit := len(out.Data)
|
|
||||||
if limit > 50 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
for i := 0; i < limit; i++ {
|
|
||||||
it := out.Data[i]
|
|
||||||
cycleText := formatCycleText(it.CycleUnit, it.CycleValue, it.Cycle)
|
|
||||||
line := fmt.Sprintf("- ID=%s | %s | 分类:%s | 到期:%s | 周期:%s | 金额:%.2f %s", it.ID, it.Name, it.Category, it.DueDate, cycleText, it.Amount, it.Currency)
|
|
||||||
if strings.TrimSpace(it.Account) != "" {
|
|
||||||
line += fmt.Sprintf(" | 账号:%s", it.Account)
|
|
||||||
}
|
|
||||||
lines = append(lines, line)
|
|
||||||
}
|
|
||||||
if len(out.Data) > limit {
|
|
||||||
lines = append(lines, fmt.Sprintf("... 共 %d 个,已展示前 %d 个", len(out.Data), limit))
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n"), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) del(userID int64, id string) (string, error) {
|
|
||||||
if strings.TrimSpace(id) == "" {
|
|
||||||
return "❌ id 不能为空", nil
|
|
||||||
}
|
|
||||||
cfg, err := m.loadSettings()
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions/" + id + "?user_id=1&channel=qq"
|
|
||||||
body, status, err := m.doRequest(http.MethodDelete, url, nil, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, err.Error()), nil
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("upstream http %d: %s", status, body)), nil
|
|
||||||
}
|
|
||||||
var out simpleResp
|
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, "响应解析失败"), nil
|
|
||||||
}
|
|
||||||
if !out.Success {
|
|
||||||
return ecode.Tag(ecode.ErrStepFailed, out.Error), nil
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("✅ 订阅已删除:%s", id), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) loadSettings() (*settings, error) {
|
|
||||||
if m.db == nil {
|
|
||||||
return nil, errors.New("db 未初始化")
|
|
||||||
}
|
|
||||||
keys := []string{"sub_api_base", "sub_api_token"}
|
|
||||||
cfg := &settings{}
|
|
||||||
for _, k := range keys {
|
|
||||||
var s models.AppSetting
|
|
||||||
val := ""
|
|
||||||
if err := m.db.Where("key = ?", k).First(&s).Error; err == nil {
|
|
||||||
val = strings.TrimSpace(s.Value)
|
|
||||||
}
|
|
||||||
switch k {
|
|
||||||
case "sub_api_base":
|
|
||||||
cfg.APIBase = strings.TrimSpace(val)
|
|
||||||
case "sub_api_token":
|
|
||||||
cfg.Token = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.APIBase == "" {
|
|
||||||
return nil, errors.New("sub_api_base 未配置")
|
|
||||||
}
|
|
||||||
if cfg.Token == "" {
|
|
||||||
return nil, errors.New("sub_api_token 未配置")
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) doRequest(method, url string, body []byte, cfg *settings) (string, int, error) {
|
|
||||||
req, err := http.NewRequest(method, url, strings.NewReader(string(body)))
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
if cfg.Token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
|
||||||
}
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
return string(data), resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package sub
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type reportSummaryResp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data struct {
|
|
||||||
Total int `json:"total"`
|
|
||||||
Currency []struct {
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
Cnt int `json:"cnt"`
|
|
||||||
Total float64 `json:"total"`
|
|
||||||
} `json:"currency"`
|
|
||||||
Due struct {
|
|
||||||
Overdue int `json:"overdue"`
|
|
||||||
Due7 int `json:"due7"`
|
|
||||||
Due30 int `json:"due30"`
|
|
||||||
} `json:"due"`
|
|
||||||
} `json:"data"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type reportListResp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data []map[string]any `json:"data"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Module) report(userID int64, args []string) (string, error) {
|
|
||||||
cfg, err := m.loadSettings()
|
|
||||||
if err != nil {
|
|
||||||
return "❌ " + err.Error(), nil
|
|
||||||
}
|
|
||||||
typeName := "summary"
|
|
||||||
days := ""
|
|
||||||
if len(args) >= 1 {
|
|
||||||
typeName = strings.ToLower(strings.TrimSpace(args[0]))
|
|
||||||
}
|
|
||||||
if len(args) >= 2 {
|
|
||||||
days = strings.TrimSpace(args[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
base := strings.TrimRight(cfg.APIBase, "/") + "/api/report?user_id=1&channel=qq&type=" + typeName
|
|
||||||
if days != "" {
|
|
||||||
base += "&days=" + days
|
|
||||||
}
|
|
||||||
body, status, err := m.doRequest(http.MethodGet, base, nil, cfg)
|
|
||||||
if err != nil {
|
|
||||||
return "❌ 报表请求失败: " + err.Error(), nil
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
return fmt.Sprintf("❌ 报表失败: http %d %s", status, body), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch typeName {
|
|
||||||
case "summary":
|
|
||||||
var out reportSummaryResp
|
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
|
||||||
return "❌ 报表解析失败", nil
|
|
||||||
}
|
|
||||||
if !out.Success {
|
|
||||||
return "❌ " + out.Error, nil
|
|
||||||
}
|
|
||||||
lines := []string{fmt.Sprintf("📊 订阅总览:%d 项", out.Data.Total)}
|
|
||||||
if len(out.Data.Currency) > 0 {
|
|
||||||
lines = append(lines, "💰 按币种汇总:")
|
|
||||||
for _, c := range out.Data.Currency {
|
|
||||||
lines = append(lines, fmt.Sprintf("- %s: %.2f(%d项)", c.Currency, c.Total, c.Cnt))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines = append(lines, fmt.Sprintf("⏰ 到期:7天内=%d,30天内=%d,已逾期=%d", out.Data.Due.Due7, out.Data.Due.Due30, out.Data.Due.Overdue))
|
|
||||||
return strings.Join(lines, "\n"), nil
|
|
||||||
case "category", "cycle", "due":
|
|
||||||
var out reportListResp
|
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil {
|
|
||||||
return "❌ 报表解析失败", nil
|
|
||||||
}
|
|
||||||
if !out.Success {
|
|
||||||
return "❌ " + out.Error, nil
|
|
||||||
}
|
|
||||||
if len(out.Data) == 0 {
|
|
||||||
return "📭 无数据", nil
|
|
||||||
}
|
|
||||||
lines := []string{fmt.Sprintf("📊 报表(%s):%d 条", typeName, len(out.Data))}
|
|
||||||
max := len(out.Data)
|
|
||||||
if max > 30 {
|
|
||||||
max = 30
|
|
||||||
}
|
|
||||||
for i := 0; i < max; i++ {
|
|
||||||
r := out.Data[i]
|
|
||||||
switch typeName {
|
|
||||||
case "category":
|
|
||||||
lines = append(lines, fmt.Sprintf("- %v | %v | 数量:%v | 金额:%.2f", r["category"], r["currency"], r["cnt"], toF64(r["total"])))
|
|
||||||
case "cycle":
|
|
||||||
lines = append(lines, fmt.Sprintf("- %v x%v | 数量:%v", r["cycle_unit"], r["cycle_value"], r["cnt"]))
|
|
||||||
case "due":
|
|
||||||
lines = append(lines, fmt.Sprintf("- %v | %v | 分类:%v | 到期:%v | 周期:%v x%v | %.2f %v", r["id"], r["name"], r["category"], r["due_date"], r["cycle_unit"], r["cycle_value"], toF64(r["amount"]), r["currency"]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(out.Data) > max {
|
|
||||||
lines = append(lines, fmt.Sprintf("... 共%d条,仅显示前%d条", len(out.Data), max))
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n"), nil
|
|
||||||
default:
|
|
||||||
return "❌ 用法:/sub report | /sub report category | /sub report cycle | /sub report due <7|30|overdue>", nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toF64(v any) float64 {
|
|
||||||
switch t := v.(type) {
|
|
||||||
case float64:
|
|
||||||
return t
|
|
||||||
case string:
|
|
||||||
f, _ := strconv.ParseFloat(t, 64)
|
|
||||||
return f
|
|
||||||
case int:
|
|
||||||
return float64(t)
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+8
-10
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
|
"ops-assistant/internal/service"
|
||||||
|
|
||||||
"ops-assistant/internal/core/ai"
|
"ops-assistant/internal/core/ai"
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
@@ -25,11 +26,12 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultUserID 统一用户ID
|
// DefaultUserID 统一用户ID,使所有平台共享同一份账本
|
||||||
const DefaultUserID int64 = 1
|
const DefaultUserID int64 = 1
|
||||||
|
|
||||||
type QQBot struct {
|
type QQBot struct {
|
||||||
api openapi.OpenAPI
|
api openapi.OpenAPI
|
||||||
|
finance *service.FinanceService
|
||||||
credentials *token.QQBotCredentials
|
credentials *token.QQBotCredentials
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
opsSvc *ops.Service
|
opsSvc *ops.Service
|
||||||
@@ -37,10 +39,11 @@ type QQBot struct {
|
|||||||
aiAutoReload time.Time
|
aiAutoReload time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQBot(db *gorm.DB, appID string, secret string, opsSvc *ops.Service) *QQBot {
|
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, opsSvc *ops.Service) *QQBot {
|
||||||
return &QQBot{
|
return &QQBot{
|
||||||
db: db,
|
db: db,
|
||||||
opsSvc: opsSvc,
|
finance: finance,
|
||||||
|
opsSvc: opsSvc,
|
||||||
credentials: &token.QQBotCredentials{
|
credentials: &token.QQBotCredentials{
|
||||||
AppID: appID,
|
AppID: appID,
|
||||||
AppSecret: secret,
|
AppSecret: secret,
|
||||||
@@ -107,15 +110,10 @@ func (b *QQBot) processAndReply(userID string, content string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// sub 引导式新增流程(优先级高于模块命令)
|
|
||||||
if reply, handled := b.handleSubFlow(userID, text); handled {
|
|
||||||
return reply
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先交给 opsSvc 处理命令
|
// 先交给 opsSvc 处理命令
|
||||||
if b.opsSvc != nil {
|
if b.opsSvc != nil {
|
||||||
if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled {
|
if handled, out := b.opsSvc.Handle(DefaultUserID, text); handled {
|
||||||
if strings.HasPrefix(text, "/cpa ") || text == "/cpa" || strings.HasPrefix(text, "/cf ") || strings.HasPrefix(text, "/mail ") || strings.HasPrefix(text, "/cfdav ") || text == "/cfdav" {
|
if strings.HasPrefix(text, "/cpa ") || text == "/cpa" || strings.HasPrefix(text, "/cf ") || strings.HasPrefix(text, "/mail ") {
|
||||||
jobID := parseJobID(out)
|
jobID := parseJobID(out)
|
||||||
if jobID > 0 {
|
if jobID > 0 {
|
||||||
go b.waitAndPushJobResult(userID, jobID, text)
|
go b.waitAndPushJobResult(userID, jobID, text)
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
package qq
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ops-assistant/models"
|
|
||||||
|
|
||||||
"github.com/tencent-connect/botgo/dto"
|
|
||||||
)
|
|
||||||
|
|
||||||
type subRemindItem struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
Account string `json:"account"`
|
|
||||||
Amount float64 `json:"amount"`
|
|
||||||
Currency string `json:"currency"`
|
|
||||||
DueDate string `json:"due_date"`
|
|
||||||
CycleUnit string `json:"cycle_unit"`
|
|
||||||
CycleValue int `json:"cycle_value"`
|
|
||||||
Remind []int `json:"remind_days"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type subListResp struct {
|
|
||||||
Success bool `json:"success"`
|
|
||||||
Data []subRemindItem `json:"data"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type subNotifySettings struct {
|
|
||||||
APIBase string
|
|
||||||
Token string
|
|
||||||
NotifyQQUserID string
|
|
||||||
NotifyGroupID string
|
|
||||||
NotifyWeComWebhook string
|
|
||||||
NotifyTime string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) StartSubScheduler(ctx context.Context, hour, minute int) {
|
|
||||||
if b.db == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
b.trySendTestOnce(ctx)
|
|
||||||
for {
|
|
||||||
next := nextRunTime(hour, minute)
|
|
||||||
t := time.NewTimer(time.Until(next))
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
t.Stop()
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
b.runSubNotifyOnce(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) trySendTestOnce(ctx context.Context) {
|
|
||||||
if b.db == nil {
|
|
||||||
log.Printf("[sub_notify] test skip: db nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var s models.AppSetting
|
|
||||||
if err := b.db.Where("key = ?", "sub_notify_test_once").First(&s).Error; err != nil {
|
|
||||||
log.Printf("[sub_notify] test skip: setting not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(s.Value) != "1" {
|
|
||||||
log.Printf("[sub_notify] test skip: flag=%s", strings.TrimSpace(s.Value))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg, err := b.loadSubNotifySettings()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[sub_notify] test skip: load settings err=%v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" {
|
|
||||||
log.Printf("[sub_notify] test skip: notify target empty")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
if b.api != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
log.Printf("[sub_notify] test skip: ctx done")
|
|
||||||
return
|
|
||||||
case <-time.After(1 * time.Second):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if b.api == nil {
|
|
||||||
log.Printf("[sub_notify] test skip: api nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg := fmt.Sprintf("✅ 订阅提醒测试 %s", time.Now().Format("01-02 15:04"))
|
|
||||||
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
|
|
||||||
if err := sendWeCom(cfg.NotifyWeComWebhook, msg); err == nil {
|
|
||||||
s.Value = "0"
|
|
||||||
_ = b.db.Save(&s).Error
|
|
||||||
log.Printf("[sub_notify] test send ok wecom")
|
|
||||||
} else {
|
|
||||||
log.Printf("[sub_notify] test send failed wecom err=%v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.NotifyGroupID) != "" {
|
|
||||||
if err := b.sendGroup(cfg.NotifyGroupID, msg); err == nil {
|
|
||||||
s.Value = "0"
|
|
||||||
_ = b.db.Save(&s).Error
|
|
||||||
log.Printf("[sub_notify] test send ok group=%s", cfg.NotifyGroupID)
|
|
||||||
} else {
|
|
||||||
log.Printf("[sub_notify] test send failed group=%s err=%v", cfg.NotifyGroupID, err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := b.sendC2C(cfg.NotifyQQUserID, msg); err == nil {
|
|
||||||
s.Value = "0"
|
|
||||||
_ = b.db.Save(&s).Error
|
|
||||||
log.Printf("[sub_notify] test send ok user=%s", cfg.NotifyQQUserID)
|
|
||||||
} else {
|
|
||||||
log.Printf("[sub_notify] test send failed user=%s err=%v", cfg.NotifyQQUserID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func nextRunTime(hour, minute int) time.Time {
|
|
||||||
now := time.Now()
|
|
||||||
loc := now.Location()
|
|
||||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, loc)
|
|
||||||
if !next.After(now) {
|
|
||||||
next = next.Add(24 * time.Hour)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) runSubNotifyOnce(ctx context.Context) {
|
|
||||||
cfg, err := b.loadSubNotifySettings()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.NotifyWeComWebhook) == "" && strings.TrimSpace(cfg.NotifyQQUserID) == "" && strings.TrimSpace(cfg.NotifyGroupID) == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := fmt.Sprintf("%s/api/subscriptions?user_id=1&channel=qq", strings.TrimRight(cfg.APIBase, "/"))
|
|
||||||
body, status, err := doSubRequest("GET", url, nil, cfg.Token)
|
|
||||||
if err != nil || status >= 400 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out subListResp
|
|
||||||
if err := json.Unmarshal([]byte(body), &out); err != nil || !out.Success {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(out.Data) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
today := now.Format("2006-01-02")
|
|
||||||
for _, it := range out.Data {
|
|
||||||
if it.DueDate == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
due, err := time.ParseInLocation("2006-01-02", it.DueDate, now.Location())
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
daysLeft := int(due.Sub(time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())).Hours() / 24)
|
|
||||||
if !shouldRemind(it.Remind, daysLeft) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
targetID := cfg.NotifyQQUserID
|
|
||||||
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
|
|
||||||
targetID = "wecom"
|
|
||||||
} else if strings.TrimSpace(cfg.NotifyGroupID) != "" {
|
|
||||||
targetID = cfg.NotifyGroupID
|
|
||||||
}
|
|
||||||
if b.hasNotified("qqbot_official", targetID, it.ID, today, daysLeft) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msg := formatSubReminder(it, daysLeft)
|
|
||||||
if msg == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(cfg.NotifyWeComWebhook) != "" {
|
|
||||||
_ = sendWeCom(cfg.NotifyWeComWebhook, msg)
|
|
||||||
} else if strings.TrimSpace(cfg.NotifyGroupID) != "" {
|
|
||||||
_ = b.sendGroup(cfg.NotifyGroupID, msg)
|
|
||||||
} else {
|
|
||||||
_ = b.sendC2C(cfg.NotifyQQUserID, msg)
|
|
||||||
}
|
|
||||||
b.markNotified("qqbot_official", targetID, it.ID, today, daysLeft)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) loadSubNotifySettings() (*subNotifySettings, error) {
|
|
||||||
if b.db == nil {
|
|
||||||
return nil, fmt.Errorf("db 未初始化")
|
|
||||||
}
|
|
||||||
get := func(key string) string {
|
|
||||||
var s models.AppSetting
|
|
||||||
if err := b.db.Where("key = ?", key).First(&s).Error; err == nil {
|
|
||||||
return strings.TrimSpace(s.Value)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
cfg := &subNotifySettings{
|
|
||||||
APIBase: get("sub_api_base"),
|
|
||||||
Token: get("sub_api_token"),
|
|
||||||
NotifyQQUserID: get("sub_notify_qq_user_id"),
|
|
||||||
NotifyGroupID: get("sub_notify_group_id"),
|
|
||||||
NotifyWeComWebhook: get("sub_notify_wecom_webhook"),
|
|
||||||
NotifyTime: get("sub_notify_time"),
|
|
||||||
}
|
|
||||||
if cfg.APIBase == "" {
|
|
||||||
return nil, fmt.Errorf("sub_api_base 未配置")
|
|
||||||
}
|
|
||||||
if cfg.Token == "" {
|
|
||||||
return nil, fmt.Errorf("sub_api_token 未配置")
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldRemind(remind []int, daysLeft int) bool {
|
|
||||||
for _, d := range remind {
|
|
||||||
if d == daysLeft {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) hasNotified(platform, userID, subID, date string, daysLeft int) bool {
|
|
||||||
var row models.SubNotifyLog
|
|
||||||
if err := b.db.Where("platform=? AND external_user_id=? AND sub_id=? AND notify_date=? AND days_left=?", platform, userID, subID, date, daysLeft).First(&row).Error; err == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) markNotified(platform, userID, subID, date string, daysLeft int) {
|
|
||||||
_ = b.db.Create(&models.SubNotifyLog{Platform: platform, ExternalUserID: userID, SubID: subID, NotifyDate: date, DaysLeft: daysLeft, CreatedAt: time.Now()}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatSubReminder(it subRemindItem, daysLeft int) string {
|
|
||||||
prefix := "⏰"
|
|
||||||
if daysLeft < 0 {
|
|
||||||
prefix = "⚠️"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s 订阅提醒:%s | 分类:%s | 到期:%s | 剩余:%d天 | 金额:%.2f %s", prefix, it.Name, it.Category, it.DueDate, daysLeft, it.Amount, it.Currency)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) sendC2C(userID, msg string) error {
|
|
||||||
if b.api == nil {
|
|
||||||
log.Printf("[sub_notify] skip send: api nil")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := b.api.PostC2CMessage(context.Background(), userID, dto.MessageToCreate{Content: msg})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[sub_notify] send failed user=%s err=%v", userID, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Printf("[sub_notify] send ok user=%s", userID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) sendGroup(groupID, msg string) error {
|
|
||||||
if b.api == nil {
|
|
||||||
log.Printf("[sub_notify] skip send group: api nil")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
_, err := b.api.PostGroupMessage(context.Background(), groupID, dto.MessageToCreate{Content: msg})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[sub_notify] send failed group=%s err=%v", groupID, err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Printf("[sub_notify] send ok group=%s", groupID)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendWeCom(webhook, msg string) error {
|
|
||||||
payload := map[string]any{
|
|
||||||
"msgtype": "text",
|
|
||||||
"text": map[string]any{"content": msg},
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(payload)
|
|
||||||
req, err := http.NewRequest(http.MethodPost, webhook, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
return fmt.Errorf("wecom status %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
package qq
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"ops-assistant/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
type subSessionPayload struct {
|
|
||||||
Step string `json:"step"`
|
|
||||||
Temp map[string]string `json:"temp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type subAPISettings struct {
|
|
||||||
APIBase string
|
|
||||||
Token string
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
subStepName = "WAIT_NAME"
|
|
||||||
subStepCategory = "WAIT_CATEGORY"
|
|
||||||
subStepAcct = "WAIT_ACCOUNT"
|
|
||||||
subStepAmount = "WAIT_AMOUNT"
|
|
||||||
subStepCurrency = "WAIT_CURRENCY"
|
|
||||||
subStepDate = "WAIT_DATE"
|
|
||||||
subStepCycleUnit = "WAIT_CYCLE_UNIT"
|
|
||||||
subStepCycleValue = "WAIT_CYCLE_VALUE"
|
|
||||||
)
|
|
||||||
|
|
||||||
var dateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
|
||||||
|
|
||||||
func (b *QQBot) handleSubFlow(userID string, text string) (string, bool) {
|
|
||||||
raw := strings.TrimSpace(text)
|
|
||||||
if !strings.HasPrefix(raw, "/sub") {
|
|
||||||
if raw == "/cancel" {
|
|
||||||
if b.hasSubSession(userID) {
|
|
||||||
b.clearSubSession(userID)
|
|
||||||
return "✅ 已取消订阅新增流程", true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
if !b.hasSubSession(userID) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if raw == "/sub" || strings.HasPrefix(raw, "/sub help") {
|
|
||||||
if b.opsSvc != nil {
|
|
||||||
if handled, out := b.opsSvc.Handle(DefaultUserID, raw); handled {
|
|
||||||
return out, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "订阅模块\n- /sub add\n- /sub list\n- /sub report\n- /sub report category\n- /sub report cycle\n- /sub report due <7|30|overdue>\n- /sub del <id>\n- /sub cancel(或 /cancel)取消新增流程", true
|
|
||||||
}
|
|
||||||
if raw == "/sub cancel" {
|
|
||||||
if b.hasSubSession(userID) {
|
|
||||||
b.clearSubSession(userID)
|
|
||||||
return "✅ 已取消订阅新增流程", true
|
|
||||||
}
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
if raw == "/sub add" || strings.HasPrefix(raw, "/sub add ") {
|
|
||||||
b.setSubSession(userID, &subSessionPayload{Step: subStepName, Temp: map[string]string{}})
|
|
||||||
return "1️⃣ 输入订阅名称(例如:Netflix)", true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
sess := b.getSubSession(userID)
|
|
||||||
if sess == nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
msg := raw
|
|
||||||
if msg == "" {
|
|
||||||
return "", true
|
|
||||||
}
|
|
||||||
if sess.Temp == nil {
|
|
||||||
sess.Temp = map[string]string{}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch sess.Step {
|
|
||||||
case subStepName:
|
|
||||||
sess.Temp["name"] = msg
|
|
||||||
sess.Step = subStepCategory
|
|
||||||
b.setSubSession(userID, sess)
|
|
||||||
return "2️⃣ 输入分类(例如:通信/视频/游戏/工具/其他,可 /skip=未分类)", true
|
|
||||||
case subStepCategory:
|
|
||||||
if msg == "/skip" {
|
|
||||||
sess.Temp["category"] = "未分类"
|
|
||||||
} else {
|
|
||||||
sess.Temp["category"] = msg
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(sess.Temp["category"]) == "" {
|
|
||||||
return "❌ 分类不能为空,请重新输入(可 /skip)", true
|
|
||||||
}
|
|
||||||
sess.Step = subStepAcct
|
|
||||||
b.setSubSession(userID, sess)
|
|
||||||
return "3️⃣ 输入账号/备注(可选,回复 /skip 跳过)", true
|
|
||||||
case subStepAcct:
|
|
||||||
if msg == "/skip" {
|
|
||||||
sess.Temp["account"] = ""
|
|
||||||
} else {
|
|
||||||
sess.Temp["account"] = msg
|
|
||||||
}
|
|
||||||
sess.Step = subStepAmount
|
|
||||||
b.setSubSession(userID, sess)
|
|
||||||
return "4️⃣ 输入金额(数字,例如 9.99)", true
|
|
||||||
case subStepAmount:
|
|
||||||
amt, err := strconv.ParseFloat(msg, 64)
|
|
||||||
if err != nil || amt < 0 {
|
|
||||||
return "❌ 金额格式错误,请输入数字(例如 9.99)", true
|
|
||||||
}
|
|
||||||
sess.Temp["amount"] = fmt.Sprintf("%.2f", amt)
|
|
||||||
sess.Step = subStepCurrency
|
|
||||||
b.setSubSession(userID, sess)
|
|
||||||
return "5️⃣ 输入币种(CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD,或 /skip 默认 CNY)", true
|
|
||||||
case subStepCurrency:
|
|
||||||
cur := strings.ToUpper(msg)
|
|
||||||
if msg == "/skip" {
|
|
||||||
cur = "CNY"
|
|
||||||
}
|
|
||||||
if !isAllowedCurrency(cur) {
|
|
||||||
return "❌ 币种不支持,请输入 CNY/USD/EUR/GBP/HKD/JPY/MYR/SGD", true
|
|
||||||
}
|
|
||||||
sess.Temp["currency"] = cur
|
|
||||||
sess.Step = subStepDate
|
|
||||||
b.setSubSession(userID, sess)
|
|
||||||
return "6️⃣ 输入到期日(YYYY-MM-DD)", true
|
|
||||||
case subStepDate:
|
|
||||||
if !dateRe.MatchString(msg) {
|
|
||||||
return "❌ 日期格式错误,请用 YYYY-MM-DD", true
|
|
||||||
}
|
|
||||||
sess.Temp["due_date"] = msg
|
|
||||||
sess.Step = subStepCycleUnit
|
|
||||||
b.setSubSession(userID, sess)
|
|
||||||
return "7️⃣ 输入周期单位(day/month/year)", true
|
|
||||||
case subStepCycleUnit:
|
|
||||||
unit := strings.ToLower(msg)
|
|
||||||
if !isAllowedCycleUnit(unit) {
|
|
||||||
return "❌ 周期单位不支持,请输入 day/month/year", true
|
|
||||||
}
|
|
||||||
sess.Temp["cycle_unit"] = unit
|
|
||||||
sess.Step = subStepCycleValue
|
|
||||||
b.setSubSession(userID, sess)
|
|
||||||
return "8️⃣ 输入周期数量(数字)", true
|
|
||||||
case subStepCycleValue:
|
|
||||||
cv, err := strconv.Atoi(msg)
|
|
||||||
if err != nil || cv <= 0 {
|
|
||||||
return "❌ 周期数量必须是正整数", true
|
|
||||||
}
|
|
||||||
sess.Temp["cycle_value"] = strconv.Itoa(cv)
|
|
||||||
b.clearSubSession(userID)
|
|
||||||
return b.subCreate(sess)
|
|
||||||
default:
|
|
||||||
b.clearSubSession(userID)
|
|
||||||
return "❌ 会话已重置,请重新发送 /sub add", true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) subCreate(sess *subSessionPayload) (string, bool) {
|
|
||||||
cfg, err := b.loadSubSettings()
|
|
||||||
if err != nil {
|
|
||||||
return "❌ 未配置 sub_api_base/sub_api_token", true
|
|
||||||
}
|
|
||||||
payload := map[string]any{
|
|
||||||
"user_id": "1",
|
|
||||||
"channel": "qq",
|
|
||||||
"name": sess.Temp["name"],
|
|
||||||
"category": sess.Temp["category"],
|
|
||||||
"account": sess.Temp["account"],
|
|
||||||
"amount": toFloat(sess.Temp["amount"]),
|
|
||||||
"currency": sess.Temp["currency"],
|
|
||||||
"due_date": sess.Temp["due_date"],
|
|
||||||
"cycle_unit": sess.Temp["cycle_unit"],
|
|
||||||
"cycle_value": toInt(sess.Temp["cycle_value"]),
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(payload)
|
|
||||||
url := strings.TrimRight(cfg.APIBase, "/") + "/api/subscriptions"
|
|
||||||
resp, status, err := doSubRequest(http.MethodPost, url, body, cfg.Token)
|
|
||||||
if err != nil {
|
|
||||||
return "❌ 创建失败: " + err.Error(), true
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
return fmt.Sprintf("❌ 创建失败: http %d %s", status, strings.TrimSpace(resp)), true
|
|
||||||
}
|
|
||||||
return "✅ 添加成功", true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) loadSubSettings() (*subAPISettings, error) {
|
|
||||||
if b.db == nil {
|
|
||||||
return nil, fmt.Errorf("db nil")
|
|
||||||
}
|
|
||||||
var base models.AppSetting
|
|
||||||
if err := b.db.Where("key = ?", "sub_api_base").First(&base).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var token models.AppSetting
|
|
||||||
if err := b.db.Where("key = ?", "sub_api_token").First(&token).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
apiBase := strings.TrimSpace(base.Value)
|
|
||||||
apiToken := strings.TrimSpace(token.Value)
|
|
||||||
if apiBase == "" || apiToken == "" {
|
|
||||||
return nil, fmt.Errorf("empty settings")
|
|
||||||
}
|
|
||||||
return &subAPISettings{APIBase: apiBase, Token: apiToken}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) hasSubSession(userID string) bool {
|
|
||||||
if b.db == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
var st models.SubSession
|
|
||||||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(st.Step) != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) getSubSession(userID string) *subSessionPayload {
|
|
||||||
if b.db == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var st models.SubSession
|
|
||||||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&st).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var payload subSessionPayload
|
|
||||||
if err := json.Unmarshal([]byte(st.TempJSON), &payload.Temp); err != nil {
|
|
||||||
payload.Temp = map[string]string{}
|
|
||||||
}
|
|
||||||
payload.Step = st.Step
|
|
||||||
return &payload
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) setSubSession(userID string, sess *subSessionPayload) {
|
|
||||||
if b.db == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tmp, _ := json.Marshal(sess.Temp)
|
|
||||||
var row models.SubSession
|
|
||||||
if err := b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").First(&row).Error; err == nil {
|
|
||||||
row.Step = sess.Step
|
|
||||||
row.TempJSON = string(tmp)
|
|
||||||
_ = b.db.Save(&row).Error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = b.db.Create(&models.SubSession{
|
|
||||||
UserID: userID,
|
|
||||||
Platform: "qqbot_official",
|
|
||||||
Step: sess.Step,
|
|
||||||
TempJSON: string(tmp),
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *QQBot) clearSubSession(userID string) {
|
|
||||||
if b.db == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = b.db.Where("user_id = ? AND platform = ?", userID, "qqbot_official").Delete(&models.SubSession{}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func doSubRequest(method, url string, body []byte, token string) (string, int, error) {
|
|
||||||
req, err := http.NewRequest(method, url, bytes.NewReader(body))
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
if token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
}
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
return string(data), resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func toInt(s string) int {
|
|
||||||
if s == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
v, _ := strconv.Atoi(s)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func toFloat(s string) float64 {
|
|
||||||
if s == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
v, _ := strconv.ParseFloat(s, 64)
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAllowedCurrency(c string) bool {
|
|
||||||
switch strings.ToUpper(c) {
|
|
||||||
case "CNY", "USD", "EUR", "GBP", "HKD", "JPY", "MYR", "SGD":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isAllowedCycleUnit(u string) bool {
|
|
||||||
switch strings.ToLower(u) {
|
|
||||||
case "day", "month", "year":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+7
-277
@@ -4,11 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -21,6 +19,7 @@ import (
|
|||||||
"ops-assistant/internal/core/ecode"
|
"ops-assistant/internal/core/ecode"
|
||||||
"ops-assistant/internal/core/ops"
|
"ops-assistant/internal/core/ops"
|
||||||
"ops-assistant/internal/core/runbook"
|
"ops-assistant/internal/core/runbook"
|
||||||
|
"ops-assistant/internal/service"
|
||||||
"ops-assistant/models"
|
"ops-assistant/models"
|
||||||
"ops-assistant/version"
|
"ops-assistant/version"
|
||||||
|
|
||||||
@@ -39,6 +38,7 @@ type WebServer struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
dbPath string
|
dbPath string
|
||||||
baseDir string
|
baseDir string
|
||||||
|
finance *service.FinanceService
|
||||||
port int
|
port int
|
||||||
username string
|
username string
|
||||||
password string
|
password string
|
||||||
@@ -88,18 +88,6 @@ type cfSettingsReq struct {
|
|||||||
APIToken string `json:"api_token"`
|
APIToken string `json:"api_token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type cfdavSettingsReq struct {
|
|
||||||
APIBase string `json:"api_base"`
|
|
||||||
AdminUser string `json:"admin_user"`
|
|
||||||
AdminPass string `json:"admin_pass"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type cfdavSettings struct {
|
|
||||||
APIBase string
|
|
||||||
AdminUser string
|
|
||||||
AdminPass string
|
|
||||||
}
|
|
||||||
|
|
||||||
type aiSettingsReq struct {
|
type aiSettingsReq struct {
|
||||||
Enabled *bool `json:"enabled"`
|
Enabled *bool `json:"enabled"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
@@ -159,11 +147,12 @@ var rolePermissions = map[string][]string{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebServer(db *gorm.DB, dbPath, baseDir string, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer {
|
func NewWebServer(db *gorm.DB, dbPath, baseDir string, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer {
|
||||||
return &WebServer{
|
return &WebServer{
|
||||||
db: db,
|
db: db,
|
||||||
dbPath: dbPath,
|
dbPath: dbPath,
|
||||||
baseDir: baseDir,
|
baseDir: baseDir,
|
||||||
|
finance: finance,
|
||||||
port: port,
|
port: port,
|
||||||
username: username,
|
username: username,
|
||||||
password: password,
|
password: password,
|
||||||
@@ -363,12 +352,6 @@ func (s *WebServer) registerAPIV1Routes(auth *gin.RouterGroup) {
|
|||||||
// Cloudflare settings
|
// Cloudflare settings
|
||||||
auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet)
|
auth.GET("/api/v1/admin/cf/settings", s.handleCFSettingsGet)
|
||||||
auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch)
|
auth.PATCH("/api/v1/admin/cf/settings", s.handleCFSettingsPatch)
|
||||||
// CFDAV settings
|
|
||||||
auth.GET("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsGet)
|
|
||||||
auth.PATCH("/api/v1/admin/cfdav/settings", s.handleCFDAVSettingsPatch)
|
|
||||||
auth.GET("/api/v1/admin/cfdav/users", s.handleCFDAVUsersList)
|
|
||||||
auth.POST("/api/v1/admin/cfdav/users", s.handleCFDAVUsersCreate)
|
|
||||||
auth.DELETE("/api/v1/admin/cfdav/users/:id", s.handleCFDAVUsersDelete)
|
|
||||||
// AI settings
|
// AI settings
|
||||||
auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet)
|
auth.GET("/api/v1/admin/ai/settings", s.handleAISettingsGet)
|
||||||
auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch)
|
auth.PATCH("/api/v1/admin/ai/settings", s.handleAISettingsPatch)
|
||||||
@@ -464,8 +447,6 @@ func (s *WebServer) RegisterRoutes(r *gin.Engine) {
|
|||||||
auth.GET("/ops", s.handleOpsPage)
|
auth.GET("/ops", s.handleOpsPage)
|
||||||
auth.GET("/cpa", s.handleCPASettingsPage)
|
auth.GET("/cpa", s.handleCPASettingsPage)
|
||||||
auth.GET("/cf", s.handleCFSettingsPage)
|
auth.GET("/cf", s.handleCFSettingsPage)
|
||||||
auth.GET("/cfdav", s.handleCFDAVSettingsPage)
|
|
||||||
auth.GET("/modules", s.handleModulesPage)
|
|
||||||
// AI 配置页临时隐藏(保留后端接口)
|
// AI 配置页临时隐藏(保留后端接口)
|
||||||
|
|
||||||
s.registerAPIV1Routes(auth)
|
s.registerAPIV1Routes(auth)
|
||||||
@@ -584,24 +565,6 @@ func (s *WebServer) handleCFSettingsPage(c *gin.Context) {
|
|||||||
s.renderPage(c, "cf_settings.html", u, nil)
|
s.renderPage(c, "cf_settings.html", u, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebServer) handleCFDAVSettingsPage(c *gin.Context) {
|
|
||||||
u := currentUser(c)
|
|
||||||
if u == nil || !s.hasPermission(u, "settings.flags.read") {
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.renderPage(c, "cfdav_settings.html", u, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) handleModulesPage(c *gin.Context) {
|
|
||||||
u := currentUser(c)
|
|
||||||
if u == nil || !s.hasPermission(u, "settings.flags.read") {
|
|
||||||
c.Redirect(http.StatusFound, "/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.renderPage(c, "modules.html", u, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) handleAISettingsPage(c *gin.Context) {
|
func (s *WebServer) handleAISettingsPage(c *gin.Context) {
|
||||||
u := currentUser(c)
|
u := currentUser(c)
|
||||||
if u == nil || !s.hasPermission(u, "settings.flags.read") {
|
if u == nil || !s.hasPermission(u, "settings.flags.read") {
|
||||||
@@ -682,7 +645,7 @@ func (s *WebServer) handleCPASettingsPatch(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
_ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error
|
_ = s.db.Create(&models.FeatureFlagHistory{FlagKey: key, OldValue: old != "", NewValue: req.ManagementBase != "", ChangedBy: u.UserID, Reason: "cpa_settings_update", RequestID: c.GetHeader("X-Request-ID")}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success")
|
s.writeAuditResult(u.UserID, "cpa.settings.update", "settings", "cpa_management", "", "", "", "success")
|
||||||
respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}})
|
respondOK(c, "success", gin.H{"keys": []string{"cpa_management_token", "cpa_management_base"}})
|
||||||
}
|
}
|
||||||
@@ -757,233 +720,6 @@ func (s *WebServer) handleCFSettingsPatch(c *gin.Context) {
|
|||||||
respondOK(c, "success", gin.H{"ok": true})
|
respondOK(c, "success", gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebServer) handleCFDAVSettingsGet(c *gin.Context) {
|
|
||||||
u := currentUser(c)
|
|
||||||
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
|
|
||||||
out := map[string]string{}
|
|
||||||
for _, k := range keys {
|
|
||||||
var sset models.AppSetting
|
|
||||||
if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
|
|
||||||
out[k] = sset.Value
|
|
||||||
} else {
|
|
||||||
out[k] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
respondOK(c, "ok", gin.H{"settings": out})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) handleCFDAVSettingsPatch(c *gin.Context) {
|
|
||||||
u := currentUser(c)
|
|
||||||
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req cfdavSettingsReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "请求体格式错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.APIBase != "" {
|
|
||||||
key := "cfdav_api_base"
|
|
||||||
var sset models.AppSetting
|
|
||||||
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
|
|
||||||
sset = models.AppSetting{Key: key, Value: req.APIBase, UpdatedBy: u.UserID}
|
|
||||||
_ = s.db.Create(&sset).Error
|
|
||||||
} else {
|
|
||||||
sset.Value = req.APIBase
|
|
||||||
sset.UpdatedBy = u.UserID
|
|
||||||
_ = s.db.Save(&sset).Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.AdminUser != "" {
|
|
||||||
key := "cfdav_admin_user"
|
|
||||||
var sset models.AppSetting
|
|
||||||
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
|
|
||||||
sset = models.AppSetting{Key: key, Value: req.AdminUser, UpdatedBy: u.UserID}
|
|
||||||
_ = s.db.Create(&sset).Error
|
|
||||||
} else {
|
|
||||||
sset.Value = req.AdminUser
|
|
||||||
sset.UpdatedBy = u.UserID
|
|
||||||
_ = s.db.Save(&sset).Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.AdminPass != "" {
|
|
||||||
key := "cfdav_admin_pass"
|
|
||||||
var sset models.AppSetting
|
|
||||||
if err := s.db.Where("key = ?", key).First(&sset).Error; err != nil {
|
|
||||||
sset = models.AppSetting{Key: key, Value: req.AdminPass, UpdatedBy: u.UserID}
|
|
||||||
_ = s.db.Create(&sset).Error
|
|
||||||
} else {
|
|
||||||
sset.Value = req.AdminPass
|
|
||||||
sset.UpdatedBy = u.UserID
|
|
||||||
_ = s.db.Save(&sset).Error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
respondOK(c, "success", gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) loadCFDAVSettings() (*cfdavSettings, error) {
|
|
||||||
keys := []string{"cfdav_api_base", "cfdav_admin_user", "cfdav_admin_pass"}
|
|
||||||
out := &cfdavSettings{}
|
|
||||||
for _, k := range keys {
|
|
||||||
var sset models.AppSetting
|
|
||||||
val := ""
|
|
||||||
if err := s.db.Where("key = ?", k).First(&sset).Error; err == nil {
|
|
||||||
val = sset.Value
|
|
||||||
}
|
|
||||||
switch k {
|
|
||||||
case "cfdav_api_base":
|
|
||||||
out.APIBase = val
|
|
||||||
case "cfdav_admin_user":
|
|
||||||
out.AdminUser = val
|
|
||||||
case "cfdav_admin_pass":
|
|
||||||
out.AdminPass = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(out.AdminUser) == "" || strings.TrimSpace(out.AdminPass) == "" {
|
|
||||||
return nil, fmt.Errorf("cfdav admin 账号未配置")
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) doCFDAVRequest(method, url string, body []byte, settings *cfdavSettings) (string, int, error) {
|
|
||||||
var reader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
reader = strings.NewReader(string(body))
|
|
||||||
}
|
|
||||||
req, err := http.NewRequest(method, url, reader)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
auth := settings.AdminUser + ":" + settings.AdminPass
|
|
||||||
b64 := base64.StdEncoding.EncodeToString([]byte(auth))
|
|
||||||
req.Header.Set("Authorization", "Basic "+b64)
|
|
||||||
if body != nil {
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
}
|
|
||||||
client := &http.Client{Timeout: 15 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return "", resp.StatusCode, err
|
|
||||||
}
|
|
||||||
return string(data), resp.StatusCode, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) handleCFDAVUsersList(c *gin.Context) {
|
|
||||||
u := currentUser(c)
|
|
||||||
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := s.loadCFDAVSettings()
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(settings.APIBase) == "" {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
|
|
||||||
resp, status, err := s.doCFDAVRequest(http.MethodGet, url, nil, settings)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondOK(c, "ok", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) handleCFDAVUsersCreate(c *gin.Context) {
|
|
||||||
u := currentUser(c)
|
|
||||||
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := s.loadCFDAVSettings()
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(settings.APIBase) == "" {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload, err := io.ReadAll(c.Request.Body)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "读取请求失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users"
|
|
||||||
resp, status, err := s.doCFDAVRequest(http.MethodPost, url, payload, settings)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.writeAuditResult(u.UserID, "cfdav.user.create", "cfdav_user", "*", "", "", "", "success")
|
|
||||||
respondOK(c, "ok", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) handleCFDAVUsersDelete(c *gin.Context) {
|
|
||||||
u := currentUser(c)
|
|
||||||
if !s.requirePerm(c, u, "settings.flags.write", "无 settings.flags.write 权限") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := s.loadCFDAVSettings()
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(settings.APIBase) == "" {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "cfdav api_base 未配置")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(c.Param("id"))
|
|
||||||
if id == "" {
|
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "id required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := strings.TrimRight(settings.APIBase, "/") + "/api/admin/users/" + id
|
|
||||||
resp, status, err := s.doCFDAVRequest(http.MethodDelete, url, nil, settings)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if status >= 400 {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(resp), &out); err != nil {
|
|
||||||
respondErr(c, http.StatusBadGateway, ecode.ErrStepFailed, "响应解析失败")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.writeAuditResult(u.UserID, "cfdav.user.delete", "cfdav_user", id, "", "", "", "success")
|
|
||||||
respondOK(c, "ok", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *WebServer) handleAISettingsGet(c *gin.Context) {
|
func (s *WebServer) handleAISettingsGet(c *gin.Context) {
|
||||||
u := currentUser(c)
|
u := currentUser(c)
|
||||||
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
|
if !s.requirePerm(c, u, "settings.flags.read", "无 settings.flags.read 权限") {
|
||||||
@@ -1980,8 +1716,6 @@ func (s *WebServer) handleModulesList(c *gin.Context) {
|
|||||||
{Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"},
|
{Module: "cpa", DisplayName: "CPA 管理", FlagKey: "enable_module_cpa"},
|
||||||
{Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"},
|
{Module: "cf", DisplayName: "CF 管理", FlagKey: "enable_module_cf"},
|
||||||
{Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"},
|
{Module: "mail", DisplayName: "邮箱管理", FlagKey: "enable_module_mail"},
|
||||||
{Module: "cfdav", DisplayName: "cfdav 管理", FlagKey: "enable_module_cfdav"},
|
|
||||||
{Module: "sub", DisplayName: "订阅管理", FlagKey: "enable_module_sub"},
|
|
||||||
}
|
}
|
||||||
for i := range items {
|
for i := range items {
|
||||||
var ff models.FeatureFlag
|
var ff models.FeatureFlag
|
||||||
@@ -2006,10 +1740,6 @@ func (s *WebServer) handleModuleToggle(c *gin.Context) {
|
|||||||
flagKey = "enable_module_cf"
|
flagKey = "enable_module_cf"
|
||||||
case "mail":
|
case "mail":
|
||||||
flagKey = "enable_module_mail"
|
flagKey = "enable_module_mail"
|
||||||
case "cfdav":
|
|
||||||
flagKey = "enable_module_cfdav"
|
|
||||||
case "sub":
|
|
||||||
flagKey = "enable_module_sub"
|
|
||||||
default:
|
default:
|
||||||
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module")
|
respondErr(c, http.StatusBadRequest, ecode.ErrStepFailed, "unknown module")
|
||||||
return
|
return
|
||||||
@@ -2067,7 +1797,7 @@ func (s *WebServer) handleDashboardOverview(c *gin.Context) {
|
|||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}
|
}
|
||||||
mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}, {Module: "cfdav"}, {Module: "sub"}}
|
mods := []moduleItem{{Module: "cpa"}, {Module: "cf"}, {Module: "mail"}}
|
||||||
for i := range mods {
|
for i := range mods {
|
||||||
flagKey := "enable_module_" + mods[i].Module
|
flagKey := "enable_module_" + mods[i].Module
|
||||||
var ff models.FeatureFlag
|
var ff models.FeatureFlag
|
||||||
@@ -2170,7 +1900,7 @@ func (s *WebServer) handleDashboardSummary(c *gin.Context) {
|
|||||||
s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed)
|
s.db.Model(&models.OpsJob{}).Where("status = ?", "failed").Count(&failed)
|
||||||
s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success)
|
s.db.Model(&models.OpsJob{}).Where("status = ?", "success").Count(&success)
|
||||||
|
|
||||||
mods := map[string]bool{"cpa": false, "cf": false, "mail": false, "cfdav": false, "sub": false}
|
mods := map[string]bool{"cpa": false, "cf": false, "mail": false}
|
||||||
for k := range mods {
|
for k := range mods {
|
||||||
var ff models.FeatureFlag
|
var ff models.FeatureFlag
|
||||||
if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil {
|
if err := s.db.Where("key = ?", "enable_module_"+k).First(&ff).Error; err == nil {
|
||||||
|
|||||||
@@ -59,23 +59,6 @@ type AppSetting struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubSession 订阅新增流程会话
|
|
||||||
// platform: qqbot_official/telegram/feishu
|
|
||||||
// user_id: 外部平台用户 ID
|
|
||||||
// temp_json: 临时收集字段
|
|
||||||
// step: WAIT_NAME/WAIT_ACCOUNT/WAIT_DATE/WAIT_CYCLE/WAIT_CUSTOM_DAYS
|
|
||||||
// 仅用于引导式新增流程
|
|
||||||
// 统一由 Bot 端维护
|
|
||||||
type SubSession struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
UserID string `gorm:"size:64;uniqueIndex:idx_sub_session_user_platform" json:"user_id"`
|
|
||||||
Platform string `gorm:"size:32;uniqueIndex:idx_sub_session_user_platform" json:"platform"`
|
|
||||||
Step string `gorm:"size:32" json:"step"`
|
|
||||||
TempJSON string `gorm:"type:text" json:"temp_json"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// FeatureFlagHistory 开关变更历史
|
// FeatureFlagHistory 开关变更历史
|
||||||
type FeatureFlagHistory struct {
|
type FeatureFlagHistory struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
@@ -127,17 +110,6 @@ type MessageDedup struct {
|
|||||||
ProcessedAt time.Time `json:"processed_at"`
|
ProcessedAt time.Time `json:"processed_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubNotifyLog 订阅提醒去重日志(避免同一天重复提醒)
|
|
||||||
type SubNotifyLog struct {
|
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
|
||||||
Platform string `gorm:"size:32;index:idx_sub_notify_unique,unique" json:"platform"`
|
|
||||||
ExternalUserID string `gorm:"size:64;index:idx_sub_notify_unique,unique" json:"external_user_id"`
|
|
||||||
SubID string `gorm:"size:64;index:idx_sub_notify_unique,unique" json:"sub_id"`
|
|
||||||
NotifyDate string `gorm:"size:10;index:idx_sub_notify_unique,unique" json:"notify_date"` // YYYY-MM-DD
|
|
||||||
DaysLeft int `gorm:"index:idx_sub_notify_unique,unique" json:"days_left"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpsJob 运维命令执行任务
|
// OpsJob 运维命令执行任务
|
||||||
type OpsJob struct {
|
type OpsJob struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
@@ -190,8 +162,6 @@ func seedDefaultFeatureFlags(db *gorm.DB) error {
|
|||||||
{Key: "enable_module_cpa", Enabled: true, RiskLevel: "low", Description: "启用 CPA 模块命令入口", RequireReason: false},
|
{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_cf", Enabled: false, RiskLevel: "medium", Description: "启用 CF 模块命令入口", RequireReason: true},
|
||||||
{Key: "enable_module_mail", Enabled: false, RiskLevel: "medium", Description: "启用 Mail 模块命令入口", RequireReason: true},
|
{Key: "enable_module_mail", Enabled: false, RiskLevel: "medium", Description: "启用 Mail 模块命令入口", RequireReason: true},
|
||||||
{Key: "enable_module_cfdav", Enabled: false, RiskLevel: "low", Description: "启用 cfdav 模块入口", RequireReason: false},
|
|
||||||
{Key: "enable_module_sub", Enabled: false, RiskLevel: "low", Description: "启用 sub 订阅模块入口", RequireReason: false},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ff := range defaults {
|
for _, ff := range defaults {
|
||||||
@@ -212,16 +182,6 @@ func seedDefaultAppSettings(db *gorm.DB) error {
|
|||||||
{Key: "ai_api_key", Value: ""},
|
{Key: "ai_api_key", Value: ""},
|
||||||
{Key: "ai_model", Value: ""},
|
{Key: "ai_model", Value: ""},
|
||||||
{Key: "ai_timeout_seconds", Value: "15"},
|
{Key: "ai_timeout_seconds", Value: "15"},
|
||||||
{Key: "cfdav_api_base", Value: ""},
|
|
||||||
{Key: "cfdav_admin_user", Value: ""},
|
|
||||||
{Key: "cfdav_admin_pass", Value: ""},
|
|
||||||
{Key: "sub_api_base", Value: ""},
|
|
||||||
{Key: "sub_api_token", Value: ""},
|
|
||||||
{Key: "sub_notify_qq_user_id", Value: ""},
|
|
||||||
{Key: "sub_notify_group_id", Value: ""},
|
|
||||||
{Key: "sub_notify_wecom_webhook", Value: ""},
|
|
||||||
{Key: "sub_notify_time", Value: "09:00"},
|
|
||||||
{Key: "sub_notify_test_once", Value: ""},
|
|
||||||
}
|
}
|
||||||
for _, s := range defaults {
|
for _, s := range defaults {
|
||||||
if err := db.Where("key = ?", s.Key).FirstOrCreate(&s).Error; err != nil {
|
if err := db.Where("key = ?", s.Key).FirstOrCreate(&s).Error; err != nil {
|
||||||
@@ -256,7 +216,6 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&ChannelConfig{},
|
&ChannelConfig{},
|
||||||
&AuditLog{},
|
&AuditLog{},
|
||||||
&MessageDedup{},
|
&MessageDedup{},
|
||||||
&SubNotifyLog{},
|
|
||||||
&OpsTarget{},
|
&OpsTarget{},
|
||||||
&OpsJob{},
|
&OpsJob{},
|
||||||
&OpsJobStep{},
|
&OpsJobStep{},
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: add_dns
|
source_step: add_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: del_dns
|
source_step: del_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: list_dns
|
source_step: list_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: resolve_dns
|
source_step: resolve_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
- id: update_dns
|
- id: update_dns
|
||||||
@@ -27,6 +26,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: update_dns
|
source_step: update_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: get_dns
|
source_step: get_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
- id: update_dns
|
- id: update_dns
|
||||||
@@ -27,6 +26,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: update_dns
|
source_step: update_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: update_dns
|
source_step: update_dns
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: list_workers
|
source_step: list_workers
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -13,6 +13,5 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: list_zones
|
source_step: list_zones
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "success"
|
- "success"
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: usage_snapshot
|
source_step: usage_snapshot
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "usage.total_requests"
|
- "usage.total_requests"
|
||||||
- "usage.total_tokens"
|
- "usage.total_tokens"
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: verify_now
|
source_step: verify_now
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "usage.total_requests"
|
- "usage.total_requests"
|
||||||
- "usage.total_tokens"
|
- "usage.total_tokens"
|
||||||
@@ -108,7 +107,6 @@ steps:
|
|||||||
on_fail: stop
|
on_fail: stop
|
||||||
with:
|
with:
|
||||||
source_step: verify_later
|
source_step: verify_later
|
||||||
expect_success: true
|
|
||||||
required_paths:
|
required_paths:
|
||||||
- "usage.total_requests"
|
- "usage.total_requests"
|
||||||
- "usage.total_tokens"
|
- "usage.total_tokens"
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
import requests
|
|
||||||
|
|
||||||
CACHE = "/root/.openclaw/workspace/fx_realtime.json"
|
|
||||||
REPORT_TZ = timezone(timedelta(hours=8))
|
|
||||||
FETCH = "/root/.openclaw/workspace/web_tasks/fx_fetch_real.js"
|
|
||||||
PAIRS = {"USD/CNY", "EUR/CNY", "SGD/CNY", "GBP/CNY", "USD/EUR", "GBP/EUR"}
|
|
||||||
PAT = re.compile(r'\[\[(20\d{2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),null,null,\[\]\],\[([0-9]+\.[0-9]+),')
|
|
||||||
|
|
||||||
def google_rate(base, quote):
|
|
||||||
s = requests.Session()
|
|
||||||
s.headers.update({"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36", "Accept-Language":"en-US,en;q=0.9"})
|
|
||||||
s.cookies.set("SOCS", "CAESHAgBEhJnd3NfMjAyMzA4MTAtMF9SQzIaAmVuIAEaBgiAo_CmBg", domain=".google.com")
|
|
||||||
u = f"https://www.google.com/finance/quote/{base}-{quote}"
|
|
||||||
r = s.get(u, timeout=15, allow_redirects=True)
|
|
||||||
hits = PAT.findall(r.text)
|
|
||||||
if not hits:
|
|
||||||
return None, None
|
|
||||||
y, mo, d, h, mi, price = max(hits, key=lambda x: tuple(int(v) for v in x[:5]))
|
|
||||||
return float(price), f"{y}-{int(mo):02d}-{int(d):02d} {int(h):02d}:{int(mi):02d} UTC"
|
|
||||||
|
|
||||||
def fresh_cache():
|
|
||||||
try:
|
|
||||||
with open(CACHE, encoding="utf-8") as f: raw = json.load(f)
|
|
||||||
gen = datetime.fromisoformat(raw.get("generatedAt", "").replace("Z", "+00:00"))
|
|
||||||
return raw if (datetime.now(timezone.utc) - gen).total_seconds() <= 600 else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def ensure_real():
|
|
||||||
raw = fresh_cache()
|
|
||||||
if raw:
|
|
||||||
return raw
|
|
||||||
try:
|
|
||||||
subprocess.run(["node", FETCH], cwd=os.path.dirname(FETCH), timeout=150, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return fresh_cache()
|
|
||||||
|
|
||||||
def network_rates(raw, base, quote):
|
|
||||||
key = f"{base}/{quote}"
|
|
||||||
rev = f"{quote}/{base}"
|
|
||||||
for name, data in (("Visa", (raw.get("visa") or {}).get(key)), ("Mastercard", (raw.get("mastercard") or {}).get(key))):
|
|
||||||
if data and data.get("ok"):
|
|
||||||
try:
|
|
||||||
orig = data["data"]["originalValues"]
|
|
||||||
fx = float(orig["fxRateVisa"])
|
|
||||||
f, t = orig.get("fromCurrency"), orig.get("toCurrency")
|
|
||||||
if f == base and t == quote: yield name, fx, data.get("queryDate") or data.get("data", {}).get("disclaimerDate", "")
|
|
||||||
elif f == quote and t == base: yield name, 1/fx, data.get("queryDate", "")
|
|
||||||
except Exception: pass
|
|
||||||
data = (raw.get("mastercard") or {}).get(key)
|
|
||||||
if data and data.get("data") and data["data"].get("data"):
|
|
||||||
try: yield "Mastercard", float(data["data"]["data"]["conversionRate"]), data["data"]["data"].get("fxDate", "")
|
|
||||||
except Exception: pass
|
|
||||||
data = (raw.get("mastercard") or {}).get(rev)
|
|
||||||
if data and data.get("data") and data["data"].get("data"):
|
|
||||||
try: yield "Mastercard", 1/float(data["data"]["data"]["conversionRate"]), data["data"]["data"].get("fxDate", "")
|
|
||||||
except Exception: pass
|
|
||||||
|
|
||||||
def parse_date(value):
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%Y/%m/%d"):
|
|
||||||
try:
|
|
||||||
return datetime.strptime(value[:10], fmt).date()
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return None
|
|
||||||
|
|
||||||
def age_label(date_text):
|
|
||||||
d = parse_date(date_text)
|
|
||||||
if not d:
|
|
||||||
return ""
|
|
||||||
days = (datetime.now(REPORT_TZ).date() - d).days
|
|
||||||
if days <= 0:
|
|
||||||
return "🟢 最新"
|
|
||||||
if days == 1:
|
|
||||||
return "🟢 1天前"
|
|
||||||
if days <= 3:
|
|
||||||
return f"🟠 {days}天前"
|
|
||||||
return f"🔴 {days}天前"
|
|
||||||
|
|
||||||
def fmt_date(date_text):
|
|
||||||
d = parse_date(date_text)
|
|
||||||
return d.isoformat() if d else (date_text or "未知日期")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) != 4: raise SystemExit("usage: rate_query.py AMOUNT BASE QUOTE")
|
|
||||||
amount = float(sys.argv[1]); base = sys.argv[2].upper(); quote = sys.argv[3].upper()
|
|
||||||
g, gt = google_rate(base, quote)
|
|
||||||
raw = ensure_real() if f"{base}/{quote}" in PAIRS or f"{quote}/{base}" in PAIRS else None
|
|
||||||
lines = [f"💱 {amount:g} {base} → {quote}", ""]
|
|
||||||
rates = []
|
|
||||||
if g is not None:
|
|
||||||
lines += ["市场参考", f"Google 1 {base} = {g:.6f} {quote}", f" {amount:g} {base} = {amount*g:.2f} {quote}"]
|
|
||||||
rates.append(("Google", g))
|
|
||||||
else:
|
|
||||||
lines += ["市场参考", "Google 暂时无法获取"]
|
|
||||||
|
|
||||||
network = []
|
|
||||||
if raw:
|
|
||||||
for name, rate, date in network_rates(raw, base, quote):
|
|
||||||
if any(n == name for n, _, _ in network):
|
|
||||||
continue
|
|
||||||
network.append((name, rate, date))
|
|
||||||
lines += ["", "卡组织汇率"]
|
|
||||||
if network:
|
|
||||||
for name, rate, date in network:
|
|
||||||
freshness = age_label(date)
|
|
||||||
date_part = f"📅 {fmt_date(date)}"
|
|
||||||
if freshness:
|
|
||||||
date_part += f" · {freshness}"
|
|
||||||
lines += [f"{name:<12}1 {base} = {rate:.6f} {quote}", f" {amount:g} {base} = {amount*rate:.2f} {quote}", f" {date_part}"]
|
|
||||||
rates.append((name, rate))
|
|
||||||
else:
|
|
||||||
lines.append("Visa/MC 暂无最新官方结算数据")
|
|
||||||
|
|
||||||
if network and g is not None:
|
|
||||||
lines += ["", "📊 相对 Google"]
|
|
||||||
for name, rate, _ in network:
|
|
||||||
diff = (rate / g - 1.0) * 100.0
|
|
||||||
sign = "+" if diff >= 0 else ""
|
|
||||||
lines.append(f"{name:<12}{sign}{diff:.3f}%")
|
|
||||||
if len(network) >= 1:
|
|
||||||
card_rates = [r for _, r, _ in network]
|
|
||||||
lo, hi = min(card_rates), max(card_rates)
|
|
||||||
if len(card_rates) == 1:
|
|
||||||
lines += ["", f"💳 卡组织参考:{amount*lo:.2f} {quote}"]
|
|
||||||
else:
|
|
||||||
lines += ["", f"💳 卡组织参考区间:{amount*lo:.2f}–{amount*hi:.2f} {quote}"]
|
|
||||||
|
|
||||||
stale = [(n, d) for n, _, d in network if parse_date(d) and (datetime.now(timezone.utc).date() - parse_date(d)).days >= 2]
|
|
||||||
if stale:
|
|
||||||
lines += ["", "⚠️ " + ";".join(f"{n} 数据距今天 {(datetime.now(REPORT_TZ).date()-parse_date(d)).days} 天" for n, d in stale) + ",不要将其视为当前实时市场价。"]
|
|
||||||
|
|
||||||
lines += ["", "Google 为当前市场参考价;Visa/万事达为官方卡组织结算汇率。实际扣款还可能受发卡行费用、入账时间等影响。"]
|
|
||||||
print("\n".join(lines))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>cfdav 配置 - Ops-Assistant</title>
|
|
||||||
<style>
|
|
||||||
:root{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--header-bg:linear-gradient(135deg,#7c2d12,#ea580c);}
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
||||||
.header{background:var(--header-bg);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
|
||||||
.header .right{display:flex;align-items:center;gap:8px}
|
|
||||||
.header a{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;line-height:28px;height:28px;display:inline-flex;align-items:center}
|
|
||||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
|
||||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
|
||||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:10px 0}
|
|
||||||
.row label{width:180px;color:var(--muted);font-size:13px}
|
|
||||||
.row input{flex:1;min-width:240px;padding:8px;border:1px solid var(--border);border-radius:6px;font-size:13px;background:#0b1220;color:var(--text)}
|
|
||||||
.btns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
|
|
||||||
button{border:none;border-radius:6px;padding:8px 12px;cursor:pointer;color:#fff;background:var(--accent);font-size:13px}
|
|
||||||
button.secondary{background:#6b7280}
|
|
||||||
button.danger{background:#9b1c1c}
|
|
||||||
small{color:var(--muted)}
|
|
||||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
|
||||||
.table th,.table td{border-bottom:1px solid var(--border);padding:8px;text-align:left}
|
|
||||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
|
||||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<div>🗂️ cfdav 管理</div>
|
|
||||||
<div class="right">
|
|
||||||
<a href="/">返回首页</a>
|
|
||||||
<a href="/logout">退出</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="card">
|
|
||||||
<h3>连接配置</h3>
|
|
||||||
<div class="row"><label>API Base</label><input id="apiBase" placeholder="例如 https://cfdav.ouai.nyc.mn"></div>
|
|
||||||
<div class="row"><label>管理员账号</label><input id="adminUser" placeholder="例如 kyy"></div>
|
|
||||||
<div class="row"><label>管理员密码</label><input id="adminPass" placeholder="请输入密码"></div>
|
|
||||||
<div class="btns">
|
|
||||||
<button onclick="saveSettings()">保存</button>
|
|
||||||
<button class="secondary" onclick="loadSettings()">刷新</button>
|
|
||||||
</div>
|
|
||||||
<small id="settingsMsg"></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>创建用户</h3>
|
|
||||||
<div class="row"><label>邮箱/用户名</label><input id="newEmail" placeholder="例如 user01"></div>
|
|
||||||
<div class="row"><label>密码</label><input id="newPass" placeholder="请输入密码"></div>
|
|
||||||
<div class="row"><label>管理员</label><input id="newAdmin" type="checkbox"></div>
|
|
||||||
<div class="btns">
|
|
||||||
<button onclick="createUser()">新增用户</button>
|
|
||||||
<button class="secondary" onclick="loadUsers()">刷新列表</button>
|
|
||||||
</div>
|
|
||||||
<small id="userMsg"></small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h3>用户列表</h3>
|
|
||||||
<table class="table">
|
|
||||||
<thead><tr><th>ID</th><th>账号</th><th>管理员</th><th>创建时间</th><th>操作</th></tr></thead>
|
|
||||||
<tbody id="usersBody"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
async function api(url,opt={}){
|
|
||||||
const r=await fetch(url,opt);
|
|
||||||
const out=await r.json().catch(()=>({}));
|
|
||||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
|
||||||
return out?.data||{};
|
|
||||||
}
|
|
||||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
|
||||||
|
|
||||||
async function loadSettings(){
|
|
||||||
try{
|
|
||||||
const d=await api('/api/v1/admin/cfdav/settings');
|
|
||||||
const s=d.settings||{};
|
|
||||||
document.getElementById('apiBase').value=s.cfdav_api_base||'';
|
|
||||||
document.getElementById('adminUser').value=s.cfdav_admin_user||'';
|
|
||||||
document.getElementById('adminPass').value=s.cfdav_admin_pass||'';
|
|
||||||
document.getElementById('settingsMsg').textContent='已加载';
|
|
||||||
}catch(e){document.getElementById('settingsMsg').textContent='加载失败:'+esc(e.message||e);}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings(){
|
|
||||||
const payload={
|
|
||||||
api_base:document.getElementById('apiBase').value.trim(),
|
|
||||||
admin_user:document.getElementById('adminUser').value.trim(),
|
|
||||||
admin_pass:document.getElementById('adminPass').value.trim()
|
|
||||||
};
|
|
||||||
try{
|
|
||||||
await api('/api/v1/admin/cfdav/settings',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
||||||
document.getElementById('settingsMsg').textContent='已保存';
|
|
||||||
}catch(e){document.getElementById('settingsMsg').textContent='保存失败:'+esc(e.message||e);}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUsers(data){
|
|
||||||
if(Array.isArray(data)) return data;
|
|
||||||
if(Array.isArray(data?.data)) return data.data;
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers(){
|
|
||||||
const tbody=document.getElementById('usersBody');
|
|
||||||
try{
|
|
||||||
const d=await api('/api/v1/admin/cfdav/users');
|
|
||||||
const list=normalizeUsers(d);
|
|
||||||
if(!list.length){tbody.innerHTML='<tr><td colspan="5"><small>暂无用户</small></td></tr>';return;}
|
|
||||||
tbody.innerHTML=list.map(u=>`<tr>
|
|
||||||
<td>${esc(u.id)}</td>
|
|
||||||
<td>${esc(u.email)}</td>
|
|
||||||
<td>${u.is_admin===1?'<span class="badge on">YES</span>':'<span class="badge off">NO</span>'}</td>
|
|
||||||
<td>${esc(u.created_at)}</td>
|
|
||||||
<td><button class="danger" onclick="deleteUser('${encodeURIComponent(u.id)}')">删除</button></td>
|
|
||||||
</tr>`).join('');
|
|
||||||
}catch(e){
|
|
||||||
tbody.innerHTML='<tr><td colspan="5"><small>加载失败:'+esc(e.message||e)+'</small></td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createUser(){
|
|
||||||
const payload={
|
|
||||||
email:document.getElementById('newEmail').value.trim(),
|
|
||||||
password:document.getElementById('newPass').value,
|
|
||||||
isAdmin:document.getElementById('newAdmin').checked
|
|
||||||
};
|
|
||||||
try{
|
|
||||||
await api('/api/v1/admin/cfdav/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
||||||
document.getElementById('userMsg').textContent='创建成功';
|
|
||||||
document.getElementById('newPass').value='';
|
|
||||||
await loadUsers();
|
|
||||||
}catch(e){document.getElementById('userMsg').textContent='创建失败:'+esc(e.message||e);}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteUser(id){
|
|
||||||
if(!confirm('确认删除该用户?')) return;
|
|
||||||
try{
|
|
||||||
await api('/api/v1/admin/cfdav/users/'+id,{method:'DELETE'});
|
|
||||||
document.getElementById('userMsg').textContent='删除成功';
|
|
||||||
await loadUsers();
|
|
||||||
}catch(e){document.getElementById('userMsg').textContent='删除失败:'+esc(e.message||e);}
|
|
||||||
}
|
|
||||||
|
|
||||||
(async function init(){
|
|
||||||
await loadSettings();
|
|
||||||
await loadUsers();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -115,7 +115,6 @@ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;b
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a id="btnCPA" href="/cpa" class="secondary hidden">🔧 CPA 配置</a>
|
<a id="btnCPA" href="/cpa" class="secondary hidden">🔧 CPA 配置</a>
|
||||||
<a id="btnCF" href="/cf" class="secondary hidden">☁️ CF 配置</a>
|
<a id="btnCF" href="/cf" class="secondary hidden">☁️ CF 配置</a>
|
||||||
<a id="btnCFDAV" href="/cfdav" class="secondary hidden">🗂️ cfdav 配置</a>
|
|
||||||
<a id="btnAI" href="/ai" class="secondary hidden" style="display:none">🤖 AI 配置</a>
|
<a id="btnAI" href="/ai" class="secondary hidden" style="display:none">🤖 AI 配置</a>
|
||||||
<a id="btnModules" href="#" class="secondary hidden" onclick="toggleModulesPanel();return false;">⚙️ 模块开关</a>
|
<a id="btnModules" href="#" class="secondary hidden" onclick="toggleModulesPanel();return false;">⚙️ 模块开关</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,7 +160,6 @@ function initUI(){
|
|||||||
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
|
document.getElementById('btnChannels').classList.toggle('hidden',!can('can_view_channels'));
|
||||||
document.getElementById('btnCPA').classList.toggle('hidden',!can('can_view_flags'));
|
document.getElementById('btnCPA').classList.toggle('hidden',!can('can_view_flags'));
|
||||||
document.getElementById('btnCF').classList.toggle('hidden',!can('can_view_flags'));
|
document.getElementById('btnCF').classList.toggle('hidden',!can('can_view_flags'));
|
||||||
document.getElementById('btnCFDAV').classList.toggle('hidden',!can('can_view_flags'));
|
|
||||||
document.getElementById('btnAI').classList.toggle('hidden',!can('can_view_flags'));
|
document.getElementById('btnAI').classList.toggle('hidden',!can('can_view_flags'));
|
||||||
document.getElementById('btnAudit').classList.toggle('hidden',!can('can_view_audit'));
|
document.getElementById('btnAudit').classList.toggle('hidden',!can('can_view_audit'));
|
||||||
document.getElementById('btnModules').classList.toggle('hidden',!can('can_view_flags'));
|
document.getElementById('btnModules').classList.toggle('hidden',!can('can_view_flags'));
|
||||||
@@ -206,7 +204,7 @@ async function loadOverview(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleModulesPanel(){
|
function toggleModulesPanel(){
|
||||||
window.location.href='/modules';
|
alert('模块开关请进入设置页(后续独立页面/弹窗)');
|
||||||
}
|
}
|
||||||
|
|
||||||
function setThemeIcon(theme){
|
function setThemeIcon(theme){
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>模块开关 - Ops-Assistant</title>
|
|
||||||
<style>
|
|
||||||
:root{--bg:#f5f7fb;--text:#222;--card:#fff;--border:#e5e7eb;--muted:#6b7280;--accent:#ee5a24;--accent-hover:#d63031;--ok:#16a34a;--off:#64748b;}
|
|
||||||
[data-theme="dark"]{--bg:#0f172a;--text:#e5e7eb;--card:#111827;--border:#1f2937;--muted:#9ca3af;--accent:#f97316;--accent-hover:#ea580c;--ok:#22c55e;--off:#94a3b8;}
|
|
||||||
*{margin:0;padding:0;box-sizing:border-box}
|
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
|
||||||
.header{background:linear-gradient(135deg,#ff6b6b,#ee5a24);color:#fff;padding:18px 20px;position:sticky;top:0;z-index:100;box-shadow:0 2px 10px rgba(0,0,0,.15);display:flex;justify-content:space-between;align-items:center;gap:10px}
|
|
||||||
.header .right{display:flex;align-items:center;gap:8px}
|
|
||||||
.header a,.header button{color:#fff;text-decoration:none;background:rgba(255,255,255,.2);padding:0 10px;border-radius:8px;font-size:13px;border:none;cursor:pointer;line-height:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;}
|
|
||||||
.header button{width:28px;padding:0;transition:transform .2s ease}
|
|
||||||
.header button.spin{transform:rotate(180deg)}
|
|
||||||
.header button svg{width:16px;height:16px;stroke:#fff;fill:none;stroke-width:2}
|
|
||||||
.wrap{max-width:980px;margin:0 auto;padding:14px}
|
|
||||||
.card{background:var(--card);border-radius:6px;padding:14px;border:1px solid var(--border);box-shadow:none;margin-bottom:10px}
|
|
||||||
.table{width:100%;border-collapse:collapse;font-size:13px}
|
|
||||||
.table th,.table td{border-bottom:1px solid var(--border);padding:10px;text-align:left}
|
|
||||||
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:12px}
|
|
||||||
.on{background:#dcfce7;color:#166534}.off{background:#e5e7eb;color:#374151}
|
|
||||||
.btn{border:none;border-radius:6px;padding:6px 10px;cursor:pointer;color:#fff;background:var(--accent);font-size:12px}
|
|
||||||
.btn.secondary{background:#6b7280}
|
|
||||||
.btn.danger{background:#9b1c1c}
|
|
||||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
|
||||||
input{padding:6px 8px;border:1px solid var(--border);border-radius:6px;font-size:12px;background:var(--card);color:var(--text)}
|
|
||||||
small{color:var(--muted)}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="header">
|
|
||||||
<div>⚙️ 模块开关 · {{.version}}</div>
|
|
||||||
<div class="right">
|
|
||||||
<button id="themeToggle" title="切换主题" aria-label="切换主题">
|
|
||||||
<svg id="themeIcon" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<circle cx="12" cy="12" r="5"></circle>
|
|
||||||
<line x1="12" y1="2" x2="12" y2="4"></line>
|
|
||||||
<line x1="12" y1="20" x2="12" y2="22"></line>
|
|
||||||
<line x1="2" y1="12" x2="4" y2="12"></line>
|
|
||||||
<line x1="20" y1="12" x2="22" y2="12"></line>
|
|
||||||
<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>
|
|
||||||
<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>
|
|
||||||
<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>
|
|
||||||
<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<a href="/">返回首页</a>
|
|
||||||
<a href="/logout">退出</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="wrap">
|
|
||||||
<div class="card">
|
|
||||||
<div class="row" style="justify-content:space-between;">
|
|
||||||
<div><strong>模块列表</strong> <small>(需要填写变更原因)</small></div>
|
|
||||||
<div class="row">
|
|
||||||
<input id="reason" placeholder="请输入变更原因" style="min-width:240px" />
|
|
||||||
<button class="btn secondary" onclick="load()">刷新</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="msg" style="margin:8px 0;color:var(--muted)"></div>
|
|
||||||
<table class="table">
|
|
||||||
<thead><tr><th>模块</th><th>开关</th><th>Flag Key</th><th>操作</th></tr></thead>
|
|
||||||
<tbody id="rows"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
let theme='dark';
|
|
||||||
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
|
||||||
async function api(url,opt={}){
|
|
||||||
const r=await fetch(url,opt);
|
|
||||||
const out=await r.json().catch(()=>({}));
|
|
||||||
if(!r.ok) throw new Error(out.message||('HTTP '+r.status));
|
|
||||||
return out?.data||{};
|
|
||||||
}
|
|
||||||
async function load(){
|
|
||||||
try{
|
|
||||||
const data=await api('/api/v1/modules');
|
|
||||||
const rows=(data.modules||[]).map(m=>{
|
|
||||||
const badge=m.enabled?'on':'off';
|
|
||||||
const label=m.enabled?'启用':'禁用';
|
|
||||||
const next=!m.enabled;
|
|
||||||
const btnClass=m.enabled?'danger':'btn';
|
|
||||||
return `<tr>
|
|
||||||
<td>${esc(m.display_name||m.module)}</td>
|
|
||||||
<td><span class="badge ${badge}">${label}</span></td>
|
|
||||||
<td><small>${esc(m.flag_key||'')}</small></td>
|
|
||||||
<td><button class="btn ${btnClass}" onclick="toggle('${esc(m.module)}',${next})">${next?'启用':'禁用'}</button></td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
document.getElementById('rows').innerHTML=rows || '<tr><td colspan="4">暂无模块</td></tr>';
|
|
||||||
document.getElementById('msg').textContent='';
|
|
||||||
}catch(e){
|
|
||||||
document.getElementById('msg').textContent='加载失败:'+(e.message||e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function toggle(module, enabled){
|
|
||||||
const reason=(document.getElementById('reason').value||'').trim();
|
|
||||||
if(!reason){alert('请填写变更原因');return;}
|
|
||||||
try{
|
|
||||||
await api('/api/v1/modules/'+module+'/toggle',{
|
|
||||||
method:'POST',
|
|
||||||
headers:{'Content-Type':'application/json'},
|
|
||||||
body:JSON.stringify({enabled,reason})
|
|
||||||
});
|
|
||||||
await load();
|
|
||||||
}catch(e){alert('操作失败:'+(e.message||e));}
|
|
||||||
}
|
|
||||||
function setThemeIcon(theme){
|
|
||||||
const icon=document.getElementById('themeIcon');
|
|
||||||
if(!icon) return;
|
|
||||||
if(theme==='dark'){
|
|
||||||
icon.innerHTML='<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3.1 7 7 0 0 0 21 12.8z"></path>';
|
|
||||||
}else{
|
|
||||||
icon.innerHTML='<circle cx="12" cy="12" r="5"></circle>'+
|
|
||||||
'<line x1="12" y1="2" x2="12" y2="4"></line>'+
|
|
||||||
'<line x1="12" y1="20" x2="12" y2="22"></line>'+
|
|
||||||
'<line x1="2" y1="12" x2="4" y2="12"></line>'+
|
|
||||||
'<line x1="20" y1="12" x2="22" y2="12"></line>'+
|
|
||||||
'<line x1="4.2" y1="4.2" x2="5.8" y2="5.8"></line>'+
|
|
||||||
'<line x1="18.2" y1="18.2" x2="19.8" y2="19.8"></line>'+
|
|
||||||
'<line x1="18.2" y1="5.8" x2="19.8" y2="4.2"></line>'+
|
|
||||||
'<line x1="4.2" y1="19.8" x2="5.8" y2="18.2"></line>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function applyTheme(t){
|
|
||||||
theme=t; if(theme==='dark'){document.documentElement.setAttribute('data-theme','dark');}
|
|
||||||
else{document.documentElement.removeAttribute('data-theme');}
|
|
||||||
localStorage.setItem('theme',theme); setThemeIcon(theme);
|
|
||||||
}
|
|
||||||
function toggleTheme(){
|
|
||||||
const next=theme==='light'?'dark':'light';
|
|
||||||
const btn=document.getElementById('themeToggle');
|
|
||||||
if(btn){btn.classList.add('spin'); setTimeout(()=>btn.classList.remove('spin'),200);}
|
|
||||||
applyTheme(next);
|
|
||||||
}
|
|
||||||
(function(){
|
|
||||||
const saved=localStorage.getItem('theme')||'dark';
|
|
||||||
applyTheme(saved);
|
|
||||||
const btn=document.getElementById('themeToggle');
|
|
||||||
if(btn){ btn.addEventListener('click',toggleTheme); }
|
|
||||||
load();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user