Compare commits

..
6 Commits
13 changed files with 279 additions and 521 deletions
+23 -37
View File
@@ -1,12 +1,11 @@
# 🛠️ Ops-Assistant # 🛠️ Ops-Assistant
多平台 Bot + 自动化运维一体的私人助理服务:**智能记账**、**Runbook 工单执行**、**模块化 OPS 命令**(Cloudflare / CPA / 订阅 / cfdav / 邮箱),带 Web 管理后台。 多平台 Bot + 自动化运维一体的私人助理服务:**Runbook 工单执行**、**模块化 OPS 命令**(Cloudflare / CPA / 订阅 / cfdav / 邮箱),带 Web 管理后台。
> 项目由 Xiaji-Go(记账机器人)演进更名而来;当前 `VERSION` 为 `0.0.1`(框架重构后重新起版,历史记账版本线为 v1.1.x)。 > 当前 `VERSION` 为 `0.0.1`。项目定位为模块化 OPS 助手。
## 🆕 框架版更新要点 ## 🆕 框架版更新要点
- **项目更名**:`xiaji-go` → `ops-assistant`(go.mod / 二进制 / systemd 同步)
- **模块化命令框架**:`internal/core/module` 统一命令模板、门控(feature flag / confirm / dry-run)、错误标签(ecode)、结果格式化回传 - **模块化命令框架**:`internal/core/module` 统一命令模板、门控(feature flag / confirm / dry-run)、错误标签(ecode)、结果格式化回传
- **Runbook 执行器增强**(`internal/core/runbook`): - **Runbook 执行器增强**(`internal/core/runbook`):
- 新增动作:`http.get` / `http.post` / `assert.json`(支持 `expect_success` 严格断言,API 失败不再假阳性) - 新增动作:`http.get` / `http.post` / `assert.json`(支持 `expect_success` 严格断言,API 失败不再假阳性)
@@ -14,18 +13,16 @@
- 任务与步骤全程落库(`ops_jobs` / `ops_job_steps`),Web 后台可查 - 任务与步骤全程落库(`ops_jobs` / `ops_job_steps`),Web 后台可查
- **查询类命令结果直接回传**(此前仅回执 job 号) - **查询类命令结果直接回传**(此前仅回执 job 号)
- **CF 模块增强**:DNS 查询支持**传域名自动解析 zone_id**;`dns list / zones / workers list` 结果可读回传;API `success:false` 时任务真失败并回传错误摘要 - **CF 模块增强**:DNS 查询支持**传域名自动解析 zone_id**;`dns list / zones / workers list` 结果可读回传;API `success:false` 时任务真失败并回传错误摘要
- **新增模块**:`cpa`(usage 备份/恢复)、`cfdav`、`mail`、`sub`(订阅管理与到期提醒) - **新增模块**:`cpa`(usage 备份/恢复)、`cfdav`、`mail`、`sub`(订阅管理与到期提醒)、`rate`(实时多源汇率与卡组织结算价对比)
- **QQ Bot**:AI 命令加载器、每日订阅提醒调度(09:00) - **QQ Bot**:AI 命令加载器、每日订阅提醒调度(09:00)
- **Web 后台**:模块设置页(CF / CPA / cfdav / AI)、OPS 任务页、渠道管理、审计页 - **Web 后台**:模块设置页(CF / CPA / cfdav / AI)、OPS 任务页、渠道管理、审计页
## ✨ 功能特性 ## ✨ 功能特性
- **多平台接入**:QQ 官方 Bot(WebSocket)、Telegram Bot、飞书(通道层预留) - **多平台接入**:QQ 官方 Bot(WebSocket)、Telegram Bot、飞书(通道层预留)
- **智能记账**:自然语言识别金额与消费分类(jieba 分词,150+ 预设关键词) - **OPS 命令**:`/cf` `/cpa` `/cfdav` `/sub` `/mail` `/rate`,写操作带 dry-run / 确认门控
- **OPS 命令**:`/cf` `/cpa` `/cfdav` `/sub` `/mail`,写操作带 dry-run / 确认门控
- **Runbook 自动化**:YAML 定义、步骤编排、断言校验、任务审计 - **Runbook 自动化**:YAML 定义、步骤编排、断言校验、任务审计
- **Web 管理后台**:记录查看 / 删除 / CSV 导出、模块与渠道配置、任务与审计查看 - **Web 管理后台**:模块与渠道配置、任务与审计查看
- **金额精确**:分(int64)存储,避免浮点误差
- **优雅关闭**:SIGTERM/SIGINT 干净退出 - **优雅关闭**:SIGTERM/SIGINT 干净退出
## 📦 快速开始 ## 📦 快速开始
@@ -53,7 +50,7 @@ docker run -d --name ops-assistant -p 9621:9621 \
### 从源码编译 ### 从源码编译
```bash ```bash
# 需要 Go 1.22+、GCC(gojieba 依赖 CGO) # 需要 Go 1.22+
make build make build
make build-linux make build-linux
``` ```
@@ -66,7 +63,7 @@ server:
key: "your-secret-key" # 会话密钥 key: "your-secret-key" # 会话密钥
database: database:
path: "./xiaji.db" # SQLite 数据库路径 path: "./ops.db" # SQLite 数据库路径
admin: admin:
username: "admin" # Web 后台用户名 username: "admin" # Web 后台用户名
@@ -84,28 +81,9 @@ qqbot:
> **模块凭据**(CF API Token、CPA 管理令牌、cfdav 账号等)保存在数据库 `app_settings` 表,通过 Web 后台「模块设置」页配置,**不写入配置文件**;渠道 secrets 在数据库内加密存储(`enc:v1:` 前缀)。 > **模块凭据**(CF API Token、CPA 管理令牌、cfdav 账号等)保存在数据库 `app_settings` 表,通过 Web 后台「模块设置」页配置,**不写入配置文件**;渠道 secrets 在数据库内加密存储(`enc:v1:` 前缀)。
## 💬 记账功能 ## 🤖 Bot 与 OPS
### QQ Bot 关键词 Bot 主要用于接收 OPS 命令并返回执行结果。支持 QQ Bot、Telegram Bot;具体渠道开关与凭据在 Web 管理后台配置。
| 关键词 | 说明 |
|--------|------|
| `帮助` / `菜单` | 显示帮助 |
| `查看` / `记录` / `列表` | 最近 10 条记录 |
| `今日` / `今天` | 今日消费汇总 |
| 直接发消息 | 自动记账(如"午饭 25元") |
### Telegram Bot 命令
`/start` `/help` `/list` `/today`,或直接发消息自动记账。
### 支持格式与分类
```
午饭 25元 / 打车 ¥30 / 买咖啡15块 / 超市买水果38.5
```
预设 150+ 关键词覆盖:🍜 餐饮、🚗 交通、🛒 购物、☕ 饮品、🍎 水果、🍪 零食、🏠 住房、📱 通讯、💊 医疗、🎮 娱乐、📚 教育、🚬 烟酒。
## 🧰 OPS 命令参考 ## 🧰 OPS 命令参考
@@ -133,6 +111,17 @@ qqbot:
| `/cpa usage backup` | 实时导出 usage 并打包备份(含 sha256/meta) | | `/cpa usage backup` | 实时导出 usage 并打包备份(含 sha256/meta) |
| `/cpa usage restore <备份包>` | 恢复 usage(import 接口优先,不停服) | | `/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`) |
### 其他模块 ### 其他模块
| 命令 | 说明 | | 命令 | 说明 |
@@ -163,7 +152,6 @@ qqbot:
访问 `http://localhost:9621`: 访问 `http://localhost:9621`:
- 📊 记账统计 / 最近记录 / 分类筛选 / CSV 导出
- 🧩 模块设置:CF / CPA / cfdav / AI - 🧩 模块设置:CF / CPA / cfdav / AI
- 🔌 渠道管理:QQ / Telegram / 飞书 配置与连接测试 - 🔌 渠道管理:QQ / Telegram / 飞书 配置与连接测试
- 🧾 OPS 任务与目标主机管理 - 🧾 OPS 任务与目标主机管理
@@ -189,12 +177,10 @@ ops-assistant/
│ │ ├── runbook/ # 执行器(ssh/shell/http/assert) │ │ ├── runbook/ # 执行器(ssh/shell/http/assert)
│ │ ├── ops/ # OPS 服务编排 │ │ ├── ops/ # OPS 服务编排
│ │ └── ai/ # AI 命令加载 │ │ └── ai/ # AI 命令加载
│ ├── module/ # 业务模块:cf / cpa / cfdav / mail / sub │ ├── module/ # 业务模块:cf / cpa / cfdav / mail / sub / rate
│ ├── bot/ # Telegram Bot │ ├── bot/ # Telegram Bot
│ ├── qq/ # QQ Bot(WebSocket)+ 订阅提醒调度 │ ├── qq/ # QQ Bot(WebSocket)+ 订阅提醒调度
│ ├── feishu/ # 飞书适配(预留) │ ├── feishu/ # 飞书适配(预留)
│ ├── service/ # 记账核心(jieba 分词)
│ ├── chart/ # 统计图表
│ └── web/ # Web 后台(Gin) │ └── web/ # Web 后台(Gin)
├── runbooks/ # 自动化 runbook(YAML) ├── runbooks/ # 自动化 runbook(YAML)
├── templates/ # Web 前端页面 ├── templates/ # Web 前端页面
@@ -224,9 +210,9 @@ go build -tags runbooktest -o runbook_test ./cmd
## 📋 技术栈 ## 📋 技术栈
- **语言**: Go 1.22(CGO:gojieba) - **语言**: Go 1.22
- **Web 框架**: Gin · **数据库**: SQLite (GORM) - **Web 框架**: Gin · **数据库**: SQLite (GORM)
- **分词**: gojieba · **QQ SDK**: tencent-connect/botgo v0.2.1(WebSocket)· **TG SDK**: go-telegram-bot-api v5 **QQ SDK**: tencent-connect/botgo v0.2.1(WebSocket)· **TG SDK**: go-telegram-bot-api v5
- **自动化**: 自研 runbook 执行器(YAML 编排 + 严格断言 + 任务审计) - **自动化**: 自研 runbook 执行器(YAML 编排 + 严格断言 + 任务审计)
## 📄 License ## 📄 License
+4 -8
View File
@@ -16,7 +16,6 @@ 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"
@@ -66,9 +65,6 @@ 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)
} }
@@ -79,7 +75,7 @@ func main() {
defer cancel() defer cancel()
if cfg.Telegram.Enabled { if cfg.Telegram.Enabled {
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance, opsSvc) tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, opsSvc)
if err != nil { if err != nil {
log.Printf("⚠️ TG Bot 启动失败: %v", err) log.Printf("⚠️ TG Bot 启动失败: %v", err)
} else { } else {
@@ -88,7 +84,7 @@ func main() {
} }
if cfg.QQBot.Enabled { if cfg.QQBot.Enabled {
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance, opsSvc) qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, opsSvc)
go qqBot.Start(ctx) go qqBot.Start(ctx)
go qqBot.StartSubScheduler(ctx, 9, 0) go qqBot.StartSubScheduler(ctx, 9, 0)
} }
@@ -104,11 +100,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, ".", finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn) webServer := web.NewWebServer(db, cfg.Database.Path, ".", 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, finance, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey) fsBot := feishu.NewBot(db, 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)
} }
+13 -145
View File
@@ -7,31 +7,28 @@ 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
finance *service.FinanceService db *gorm.DB
db *gorm.DB opsSvc *ops.Service
opsSvc *ops.Service
} }
func NewTGBot(db *gorm.DB, token string, finance *service.FinanceService, opsSvc *ops.Service) (*TGBot, error) { func NewTGBot(db *gorm.DB, token string, 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, finance: finance, db: db, opsSvc: opsSvc}, nil return &TGBot{api: bot, db: db, opsSvc: opsSvc}, nil
} }
func (b *TGBot) Start(ctx context.Context) { func (b *TGBot) Start(ctx context.Context) {
@@ -91,71 +88,14 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
} }
} }
var reply string reply := ""
switch text {
switch { case "/start":
case text == "/start": reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n模块化运维命令助手,支持:\n• /cpa - CPA 管理\n• /cf - Cloudflare 管理\n• /cfdav - cfdav 管理\n• /sub - 订阅管理\n• /mail - 邮箱管理\n\n/help - 使用说明"
reply = "🛠️ 欢迎使用 Ops-Assistant!\n\n直接发送消费描述即可记账,例如:\n• 午饭 25元\n• 打车 ¥30\n• 买咖啡15块\n\n命令:\n/list - 查看最近记录\n/today - 今日汇总\n/chart - 本月图表\n/help - 帮助" case "/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:
amount, category, err := b.finance.AddTransaction(DefaultUserID, text) reply = "❓ 无法识别,输入 /help 查看支持的命令"
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)
@@ -163,75 +103,3 @@ func (b *TGBot) handleMessage(msg *tgbotapi.Message) {
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)
}
}
-126
View File
@@ -1,126 +0,0 @@
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
}
+3
View File
@@ -9,6 +9,7 @@ import (
"ops-assistant/internal/module/cfdav" "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" "ops-assistant/internal/module/sub"
"gorm.io/gorm" "gorm.io/gorm"
@@ -20,12 +21,14 @@ 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) cfdavModule := cfdav.New(db)
subModule := sub.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("cfdav", cfdavModule.Handle)
r.RegisterModule("sub", subModule.Handle) r.RegisterModule("sub", subModule.Handle)
return NewService(dbPath, baseDir, r) return NewService(dbPath, baseDir, r)
+3 -2
View File
@@ -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" && s.db != nil { if cmd.Module != "" && cmd.Module != "ops" && cmd.Module != "rate" && 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)
} }
@@ -65,7 +65,8 @@ func (s *Service) helpText() string {
"- /mail (邮箱状态)", "- /mail (邮箱状态)",
"- /cfdav (CFDAV 用户管理)", "- /cfdav (CFDAV 用户管理)",
"- /sub (订阅管理)", "- /sub (订阅管理)",
"提示:发送 /cpa /cf /mail /cfdav /sub 获取各模块完整指令清单。", "- /rate (实时汇率转换)",
"提示:发送 /cpa /cf /mail /cfdav /sub /rate 获取各模块完整指令清单。",
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
+11 -37
View File
@@ -2,28 +2,24 @@ 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
@@ -31,10 +27,9 @@ type Bot struct {
encryptKey string encryptKey string
} }
func NewBot(db *gorm.DB, finance *service.FinanceService, opsSvc *ops.Service, appID, appSecret, verificationToken, encryptKey string) *Bot { func NewBot(db *gorm.DB, 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,
@@ -56,29 +51,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(http.StatusBadRequest, gin.H{"error": "bad request"}) c.JSON(400, 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(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(400, gin.H{"error": err.Error()})
return return
} }
if verifyChallenge != "" { if verifyChallenge != "" {
c.JSON(http.StatusOK, gin.H{"challenge": verifyChallenge}) c.JSON(200, gin.H{"challenge": verifyChallenge})
return return
} }
if msg == nil { if msg == nil {
c.JSON(http.StatusOK, gin.H{"code": 0}) c.JSON(200, 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(http.StatusOK, gin.H{"code": 0}) c.JSON(200, 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
@@ -91,7 +86,7 @@ func (b *Bot) handleWebhook(c *gin.Context) {
} }
} }
c.JSON(http.StatusOK, gin.H{"code": 0}) c.JSON(200, gin.H{"code": 0})
} }
func (b *Bot) handleText(text string) string { func (b *Bot) handleText(text string) string {
@@ -103,31 +98,10 @@ 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• 午饭 25元\n• 打车 ¥30\n\n📋 命令:记录/查看、今日/今天、统计" return "🛠️ Ops-Assistant\n\n模块化运维命令助手,支持:\n• /cpa - CPA 管理\n• /cf - Cloudflare 管理\n• /cfdav - cfdav 管理\n• /sub - 订阅管理\n• /mail - 邮箱管理"
case "查看", "记录", "列表", "最近": default:
items, err := b.finance.GetTransactions(DefaultUserID, 10) return "❓ 无法识别,请使用 OPS 命令(发送「帮助」查看说明)。"
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 {
+41
View File
@@ -0,0 +1,41 @@
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 为市场参考价。"
}
+28
View File
@@ -0,0 +1,28 @@
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
}
+4 -7
View File
@@ -11,7 +11,6 @@ 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"
@@ -26,12 +25,11 @@ 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
@@ -39,11 +37,10 @@ type QQBot struct {
aiAutoReload time.Time aiAutoReload time.Time
} }
func NewQQBot(db *gorm.DB, appID string, secret string, finance *service.FinanceService, opsSvc *ops.Service) *QQBot { func NewQQBot(db *gorm.DB, appID string, secret string, opsSvc *ops.Service) *QQBot {
return &QQBot{ return &QQBot{
db: db, db: db,
finance: finance, opsSvc: opsSvc,
opsSvc: opsSvc,
credentials: &token.QQBotCredentials{ credentials: &token.QQBotCredentials{
AppID: appID, AppID: appID,
AppSecret: secret, AppSecret: secret,
-154
View File
@@ -1,154 +0,0 @@
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
}
+1 -4
View File
@@ -21,7 +21,6 @@ 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"
@@ -40,7 +39,6 @@ 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
@@ -161,12 +159,11 @@ var rolePermissions = map[string][]string{
}, },
} }
func NewWebServer(db *gorm.DB, dbPath, baseDir string, finance *service.FinanceService, port int, username, password, sessionKey string, reloadFn func() (string, error)) *WebServer { func NewWebServer(db *gorm.DB, dbPath, baseDir string, 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,
+147
View File
@@ -0,0 +1,147 @@
#!/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()