├── data └── .gitkeep ├── docs └── .gitkeep ├── backend ├── src │ ├── models │ │ └── .gitkeep │ ├── routes │ │ ├── tokenRoutes.js │ │ ├── autoSyncRoutes.js │ │ └── billRoutes.js │ ├── database │ │ ├── init.js │ │ ├── connection.js │ │ ├── README.md │ │ ├── models │ │ │ ├── Token.js │ │ │ ├── MembershipTierLimit.js │ │ │ ├── AutoSyncConfig.js │ │ │ └── SyncHistory.js │ │ └── schema.sql │ ├── middleware │ │ └── auth.js │ ├── controllers │ │ ├── tokenController.js │ │ ├── autoSyncController.js │ │ └── billController.js │ ├── index.js │ ├── services │ │ ├── autoSyncService.js │ │ └── apiService.js │ └── utils │ │ └── dataTransform.js ├── .env.example └── package.json ├── frontend ├── src │ ├── utils │ │ └── .gitkeep │ ├── components │ │ ├── .gitkeep │ │ ├── StatCardVertical.vue │ │ ├── ProductBarEChart.vue │ │ ├── ProductPieChart.vue │ │ ├── HourlyChart.vue │ │ ├── HourlyEChart.vue │ │ └── ProductPieEChart.vue │ ├── main.js │ ├── router │ │ └── index.js │ ├── api │ │ └── index.js │ ├── composables │ │ └── useApiCall.js │ ├── App.vue │ └── views │ │ ├── Settings.vue │ │ └── Onboarding.vue ├── index.html ├── vite.config.js └── package.json ├── .gitignore ├── stop.sh ├── docker-compose.yml ├── docker-compose.dev.yml ├── .dockerignore ├── LICENSE ├── docker-start.sh ├── Dockerfile ├── start.sh ├── docker-entrypoint.sh ├── DOCKER_PERMISSIONS.md ├── DOCKER.md ├── CLAUDE.md ├── nginx.conf ├── README.md └── docker-build.sh /data/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/src/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/utils/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | # 数据库配置 2 | DB_PATH=./data/expense_bills.db 3 | 4 | # API配置 5 | BIGMODEL_API_URL=https://bigmodel.cn/api/finance/expenseBill/expenseBillList 6 | 7 | # 服务器配置 8 | PORT=7965 9 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 智谱AI GLM Coding Plan 账单统计 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # 依赖 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | 7 | # 构建输出 8 | dist/ 9 | build/ 10 | docs/ 11 | 12 | # 环境变量 13 | .env 14 | .env.local 15 | .env.*.local 16 | 17 | # 数据库 18 | *.db 19 | *.sqlite 20 | *.sqlite3 21 | 22 | # IDE 23 | .vscode/ 24 | .idea/ 25 | *.swp 26 | *.swo 27 | .roo/ 28 | .claude/ 29 | 30 | # 日志 31 | logs/ 32 | *.log 33 | *.pid 34 | 35 | # 缓存 36 | .cache/ 37 | *.db 38 | *.sqlite 39 | *.sqlite3 40 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import { fileURLToPath, URL } from 'node:url' 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | resolve: { 8 | alias: { 9 | '@': fileURLToPath(new URL('./src', import.meta.url)) 10 | } 11 | }, 12 | server: { 13 | port: 3000, 14 | proxy: { 15 | '/api': { 16 | target: 'http://localhost:7965', 17 | changeOrigin: true 18 | } 19 | } 20 | } 21 | }) 22 | -------------------------------------------------------------------------------- /backend/src/routes/tokenRoutes.js: -------------------------------------------------------------------------------- 1 | // Token路由 2 | // 单用户系统,但保留Token验证功能(用于设置页面和引导页验证智谱AI Token) 3 | const express = require('express'); 4 | const router = express.Router(); 5 | const TokenController = require('../controllers/tokenController'); 6 | 7 | // 验证Token(用于设置页面和引导页) 8 | router.post('/verify', TokenController.verifyToken); 9 | 10 | // 保存Token 11 | router.post('/save', TokenController.saveToken); 12 | 13 | // 获取Token 14 | router.get('/get', TokenController.getToken); 15 | 16 | // 删除Token 17 | router.delete('/delete', TokenController.deleteToken); 18 | 19 | module.exports = router; 20 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "areyouok-backend", 3 | "version": "1.0.0", 4 | "description": "智谱AI GLM Coding Plan 账单统计 - 后端服务", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "node src/index.js", 8 | "dev": "nodemon src/index.js", 9 | "init-db": "node src/database/init.js" 10 | }, 11 | "dependencies": { 12 | "axios": "^1.6.2", 13 | "cors": "^2.8.5", 14 | "dayjs": "^1.11.19", 15 | "dotenv": "^16.3.1", 16 | "express": "^4.18.2", 17 | "sqlite3": "^5.1.6", 18 | "utc": "^0.1.0" 19 | }, 20 | "devDependencies": { 21 | "nodemon": "^3.0.2" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/routes/autoSyncRoutes.js: -------------------------------------------------------------------------------- 1 | // 自动同步配置路由 2 | const express = require('express'); 3 | const router = express.Router(); 4 | const autoSyncController = require('../controllers/autoSyncController'); 5 | 6 | // 获取自动同步配置 7 | router.get('/config', autoSyncController.getConfig.bind(autoSyncController)); 8 | 9 | // 保存自动同步配置 10 | router.post('/config', autoSyncController.saveConfig.bind(autoSyncController)); 11 | 12 | // 立即触发一次自动同步 13 | router.post('/trigger', autoSyncController.triggerSync.bind(autoSyncController)); 14 | 15 | // 停止自动同步 16 | router.post('/stop', autoSyncController.stopSync.bind(autoSyncController)); 17 | 18 | module.exports = router; 19 | -------------------------------------------------------------------------------- /stop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # stop.sh - 停止服务脚本 3 | 4 | echo "停止智谱AI GLM Coding Plan 账单统计系统" 5 | 6 | # 从PID文件读取并停止服务 7 | if [ -f .backend.pid ]; then 8 | BACKEND_PID=$(cat .backend.pid) 9 | echo "停止后端服务 (PID: $BACKEND_PID)..." 10 | kill $BACKEND_PID 2>/dev/null && echo "后端服务已停止" || echo "警告: 后端服务可能已停止" 11 | rm .backend.pid 12 | fi 13 | 14 | if [ -f .frontend.pid ]; then 15 | FRONTEND_PID=$(cat .frontend.pid) 16 | echo "停止前端服务 (PID: $FRONTEND_PID)..." 17 | kill $FRONTEND_PID 2>/dev/null && echo "前端服务已停止" || echo "警告: 前端服务可能已停止" 18 | rm .frontend.pid 19 | fi 20 | 21 | # 强制清理可能残留的进程 22 | echo "清理残留进程..." 23 | pkill -f "node.*backend" 2>/dev/null || true 24 | pkill -f "node.*frontend" 2>/dev/null || true 25 | 26 | echo "所有服务已停止" -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "areyouok-frontend", 3 | "version": "1.0.0", 4 | "description": "智谱AI GLM Coding Plan 账单统计 - 前端界面", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "axios": "^1.6.0", 12 | "chart.js": "^4.5.1", 13 | "dayjs": "^1.11.19", 14 | "echarts": "^5.6.0", 15 | "element-plus": "^2.4.0", 16 | "vue": "^3.3.0", 17 | "vue-chartjs": "^5.3.3", 18 | "vue-echarts": "^6.7.3", 19 | "vue-router": "^4.6.3" 20 | }, 21 | "devDependencies": { 22 | "@vitejs/plugin-vue": "^4.5.0", 23 | "vite": "^5.0.0" 24 | }, 25 | "main": "vite.config.js", 26 | "keywords": [], 27 | "author": "", 28 | "license": "ISC" 29 | } 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | areyouok: 5 | image: pxvp2008/areyouok-app:latest 6 | container_name: areyouok-app 7 | restart: unless-stopped 8 | ports: 9 | - "3000:3000" 10 | environment: 11 | - NODE_ENV=production 12 | - PORT=7965 13 | - TZ=Asia/Shanghai 14 | # 权限配置:动态用户ID设置 15 | - PUID=1000 16 | - PGID=1000 17 | volumes: 18 | - ./data:/app/data:rw 19 | - ./logs:/app/logs:rw 20 | healthcheck: 21 | test: ["CMD", "curl", "-f", "http://localhost/health"] 22 | interval: 30s 23 | timeout: 10s 24 | retries: 3 25 | start_period: 60s 26 | deploy: 27 | resources: 28 | limits: 29 | cpus: '1.0' 30 | memory: 512M 31 | reservations: 32 | cpus: '0.25' 33 | memory: 128M -------------------------------------------------------------------------------- /backend/src/database/init.js: -------------------------------------------------------------------------------- 1 | // 数据库初始化脚本 2 | const db = require('./connection'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | 6 | // 读取SQL文件 7 | const schemaPath = path.join(__dirname, 'schema.sql'); 8 | const schema = fs.readFileSync(schemaPath, 'utf8'); 9 | 10 | // 执行初始化 11 | db.serialize(() => { 12 | 13 | 14 | // 创建表 15 | db.exec(schema, (err) => { 16 | if (err) { 17 | console.error('数据库初始化失败:', err.message); 18 | process.exit(1); 19 | } else { 20 | 21 | } 22 | 23 | // 关闭数据库连接 24 | db.close((err) => { 25 | if (err) { 26 | console.error('关闭数据库连接失败:', err.message); 27 | process.exit(1); 28 | } else { 29 | 30 | process.exit(0); 31 | } 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | areyouok: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | target: production 9 | args: 10 | - PUID=${PUID:-1000} 11 | - PGID=${PGID:-1000} 12 | container_name: areyouok-dev 13 | ports: 14 | - "53000:3000" 15 | - "7965:7965" 16 | environment: 17 | - NODE_ENV=development 18 | - PORT=7965 19 | - TZ=Asia/Shanghai 20 | - PUID=${PUID:-1000} 21 | - PGID=${PGID:-1000} 22 | volumes: 23 | - ./data:/app/data:rw 24 | - ./logs:/app/logs:rw 25 | - ./backend/.env:/app/backend/.env:ro 26 | networks: 27 | - areyouok-network 28 | command: > 29 | sh -c " 30 | echo 'Development mode...' && 31 | /app/start.sh 32 | " 33 | deploy: 34 | resources: 35 | limits: 36 | cpus: '2.0' 37 | memory: 1G 38 | 39 | networks: 40 | areyouok-network: 41 | driver: bridge -------------------------------------------------------------------------------- /backend/src/database/connection.js: -------------------------------------------------------------------------------- 1 | // 数据库连接配置 2 | const sqlite3 = require('sqlite3').verbose(); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | 6 | // 数据库文件路径 - 从环境变量读取,未设置则使用默认路径 7 | // 环境变量中的路径相对于项目根目录解析 8 | const defaultDbPath = path.join(__dirname, '../../../data/expense_bills.db'); 9 | const DB_PATH = process.env.DB_PATH 10 | ? path.resolve(__dirname, '../../', process.env.DB_PATH) 11 | : defaultDbPath; 12 | 13 | // 确保数据库目录存在 14 | const dbDir = path.dirname(DB_PATH); 15 | if (!fs.existsSync(dbDir)) { 16 | fs.mkdirSync(dbDir, { recursive: true }); 17 | } 18 | 19 | // 创建数据库连接 20 | const db = new sqlite3.Database(DB_PATH, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE, (err) => { 21 | if (err) { 22 | console.error('数据库连接失败:', err.message); 23 | } else { 24 | // 启用外键约束 25 | db.run('PRAGMA foreign_keys = ON', (err) => { 26 | if (err) { 27 | console.error('设置PRAGMA失败:', err.message); 28 | } 29 | }); 30 | } 31 | }); 32 | 33 | module.exports = db; 34 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | .gitattributes 5 | .gitkeep 6 | 7 | # Documentation 8 | README.md 9 | CLAUDE.md 10 | docs/ 11 | 12 | # IDE 13 | .idea/ 14 | .vscode/ 15 | *.swp 16 | *.swo 17 | 18 | # OS 19 | .DS_Store 20 | Thumbs.db 21 | 22 | # Logs 23 | logs/ 24 | *.log 25 | 26 | # Node modules 27 | node_modules/ 28 | */node_modules/ 29 | 30 | # Build artifacts 31 | dist/ 32 | build/ 33 | 34 | # Environment files 35 | .env 36 | .env.local 37 | .env.production 38 | .env.development 39 | 40 | # Test coverage 41 | coverage/ 42 | .nyc_output/ 43 | 44 | # Temporary files 45 | tmp/ 46 | temp/ 47 | *.tmp 48 | 49 | # Development tools 50 | nodemon.json 51 | 52 | # Data files (will be mounted as volume) 53 | data/*.db 54 | data/*.db-journal 55 | *.db 56 | 57 | # PID files 58 | *.pid 59 | 60 | # Shell scripts (development) 61 | start.sh 62 | stop.sh 63 | 64 | # Docker startup script (needed for build) 65 | !docker-start.sh 66 | 67 | # Docker files (exclude from build context) 68 | .dockerignore 69 | Dockerfile* 70 | docker-compose*.yml 71 | 72 | # Development configuration 73 | vite.config.js -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 pxvp2008 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import ElementPlus from 'element-plus' 3 | import zhCn from 'element-plus/dist/locale/zh-cn.mjs' 4 | import 'element-plus/dist/index.css' 5 | import * as ElementPlusIconsVue from '@element-plus/icons-vue' 6 | import router from './router' 7 | import App from './App.vue' 8 | 9 | // 全局注册ECharts组件 10 | import { use } from 'echarts/core' 11 | import { CanvasRenderer } from 'echarts/renderers' 12 | import { LineChart, PieChart, BarChart } from 'echarts/charts' 13 | import { 14 | TitleComponent, 15 | TooltipComponent, 16 | LegendComponent, 17 | GridComponent, 18 | DataZoomComponent 19 | } from 'echarts/components' 20 | import VChart from 'vue-echarts' 21 | 22 | use([ 23 | CanvasRenderer, 24 | LineChart, 25 | PieChart, 26 | BarChart, 27 | TitleComponent, 28 | TooltipComponent, 29 | LegendComponent, 30 | GridComponent, 31 | DataZoomComponent 32 | ]) 33 | 34 | const app = createApp(App) 35 | 36 | for (const [key, component] of Object.entries(ElementPlusIconsVue)) { 37 | app.component(key, component) 38 | } 39 | 40 | app.use(ElementPlus, { locale: zhCn }) 41 | app.use(router) 42 | app.mount('#app') 43 | -------------------------------------------------------------------------------- /backend/src/database/README.md: -------------------------------------------------------------------------------- 1 | # 数据库目录 2 | 3 | ## 文件说明 4 | 5 | ### schema.sql 6 | 数据库表结构定义文件,包含: 7 | - expense_bills 账单明细表 8 | - 索引定义 9 | - 字段说明 10 | 11 | ### connection.js 12 | 数据库连接配置: 13 | - SQLite3 数据库连接 14 | - 连接参数设置 15 | - 自动创建数据库目录 16 | 17 | ### init.js 18 | 数据库初始化脚本: 19 | - 读取 schema.sql 20 | - 执行建表语句 21 | - 创建索引 22 | 23 | ### models/Bill.js 24 | 账单数据模型: 25 | - 数据转换逻辑 26 | - timeWindow 拆分为 timeWindowStart 和 timeWindowEnd 27 | - 从 billingNo 提取 transaction_time(13位时间戳) 28 | - 数据插入方法 29 | 30 | ### migrations/ 31 | 数据库迁移文件目录,用于管理数据库结构变更 32 | 33 | ## 数据库字段说明 34 | 35 | ### 特殊处理字段 36 | 37 | 1. **timeWindow** 38 | - 原始格式:`2025-11-05 21:09:00~2025-11-05 21:10:00` 39 | - 拆分后:`timeWindowStart` 和 `timeWindowEnd` 40 | 41 | 2. **transaction_time** 42 | - 从 `billingNo` 中提取 13 位时间戳 43 | - 转换逻辑:截取 `customerId` 后的 13 位数字 44 | - 格式:精确到毫秒的 datetime 45 | 46 | 3. **create_time** 47 | - 自动记录数据插入时间 48 | - 类型:DATETIME 49 | - 默认值:CURRENT_TIMESTAMP 50 | 51 | ## 使用方法 52 | 53 | 1. 安装依赖: 54 | ```bash 55 | npm install sqlite3 56 | ``` 57 | 58 | 2. 初始化数据库: 59 | ```bash 60 | node src/database/init.js 61 | ``` 62 | 63 | 3. 在代码中使用: 64 | ```javascript 65 | const Bill = require('./database/models/Bill'); 66 | await Bill.create(billData); 67 | ``` 68 | -------------------------------------------------------------------------------- /docker-start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | log() { 4 | echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" 5 | } 6 | 7 | wait_for_backend() { 8 | log "Waiting for backend..." 9 | for i in $(seq 1 5); do 10 | if curl -f -s http://localhost:7965/ > /dev/null 2>&1; then 11 | log "Backend ready" 12 | return 0 13 | fi 14 | sleep 1 15 | done 16 | log "Backend failed to start" 17 | return 1 18 | } 19 | 20 | init_database() { 21 | if [ ! -f "/app/data/expense_bills.db" ]; then 22 | log "Initializing database..." 23 | cd /app/backend && npm run init-db 2>&1 | tee /app/logs/db_init.log 24 | [ $? -eq 0 ] || { log "Database init failed"; exit 1; } 25 | fi 26 | } 27 | 28 | log "Starting AreYouOk..." 29 | 30 | init_database 31 | 32 | log "Starting backend..." 33 | cd /app/backend && NODE_ENV=production PORT=7965 npm start > /app/logs/backend.log 2>&1 & 34 | BACKEND_PID=$! 35 | 36 | wait_for_backend 37 | 38 | log "Starting nginx..." 39 | nginx -g 'daemon off; pid /run/nginx/nginx.pid;' \ 40 | -c /etc/nginx/nginx.conf \ 41 | -e error >/app/logs/nginx.log 2>&1 & 42 | NGINX_PID=$! 43 | 44 | shutdown() { 45 | log "Stopping services..." 46 | kill -TERM $NGINX_PID $BACKEND_PID 2>/dev/null 47 | wait $NGINX_PID $BACKEND_PID 48 | log "Stopped" 49 | exit 0 50 | } 51 | 52 | trap shutdown SIGTERM SIGINT 53 | 54 | tail -f /app/logs/backend.log & 55 | 56 | log "Services started" 57 | log "Frontend: http://localhost:3000" 58 | log "API: http://localhost:3000/api/" 59 | 60 | wait $BACKEND_PID $NGINX_PID -------------------------------------------------------------------------------- /backend/src/database/models/Token.js: -------------------------------------------------------------------------------- 1 | // Token模型 2 | const db = require('../connection'); 3 | 4 | class Token { 5 | // 保存Token 6 | static save(token) { 7 | return new Promise((resolve, reject) => { 8 | // 先删除旧Token 9 | db.run('DELETE FROM api_tokens', (err) => { 10 | if (err) { 11 | return reject(err); 12 | } 13 | 14 | // 插入新Token 15 | db.run( 16 | 'INSERT INTO api_tokens (token, provider) VALUES (?, ?)', 17 | [token, 'zhipu'], 18 | function(err) { 19 | if (err) { 20 | return reject(err); 21 | } 22 | resolve({ id: this.lastID, token, provider: 'zhipu' }); 23 | } 24 | ); 25 | }); 26 | }); 27 | } 28 | 29 | // 获取Token 30 | static get() { 31 | return new Promise((resolve, reject) => { 32 | db.get( 33 | 'SELECT * FROM api_tokens ORDER BY id DESC LIMIT 1', 34 | (err, row) => { 35 | if (err) { 36 | return reject(err); 37 | } 38 | resolve(row || null); 39 | } 40 | ); 41 | }); 42 | } 43 | 44 | // 删除Token 45 | static delete() { 46 | return new Promise((resolve, reject) => { 47 | db.run('DELETE FROM api_tokens', (err) => { 48 | if (err) { 49 | return reject(err); 50 | } 51 | resolve(); 52 | }); 53 | }); 54 | } 55 | } 56 | 57 | module.exports = Token; 58 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine AS frontend-builder 2 | WORKDIR /app/frontend 3 | RUN apk add --no-cache libc6-compat 4 | COPY frontend/package*.json ./ 5 | RUN npm ci --silent 6 | COPY frontend/ ./ 7 | RUN npm run build 8 | 9 | FROM node:18-alpine AS backend-deps 10 | WORKDIR /app 11 | RUN apk add --no-cache sqlite curl && rm -rf /var/cache/apk/* 12 | COPY backend/package*.json ./ 13 | RUN npm ci --only=production --silent && npm cache clean --force && ls -la node_modules/ 14 | 15 | FROM node:18-alpine AS production 16 | ENV NODE_ENV=production 17 | ENV PORT=7965 18 | ENV TZ=Asia/Shanghai 19 | 20 | # 设置默认用户ID,可通过环境变量覆盖 21 | ARG PUID=1001 22 | ARG PGID=1001 23 | ENV PUID=${PUID} 24 | ENV PGID=${PGID} 25 | 26 | RUN apk add --no-cache nginx sqlite curl dumb-init su-exec && \ 27 | addgroup -g 1001 -S nodejs && \ 28 | adduser -S nodejs -u 1001 && \ 29 | rm -rf /var/cache/apk/* /tmp/* /var/tmp/* 30 | 31 | WORKDIR /app 32 | 33 | COPY backend/ ./backend/ 34 | COPY --from=backend-deps /app/node_modules ./backend/node_modules/ 35 | COPY --from=frontend-builder /app/frontend/dist ./frontend/dist/ 36 | COPY nginx.conf /etc/nginx/nginx.conf 37 | COPY docker-start.sh /app/start.sh 38 | COPY docker-entrypoint.sh /app/entrypoint.sh 39 | 40 | RUN mkdir -p /app/data /app/logs /var/log/nginx /var/lib/nginx/logs /run/nginx && \ 41 | chmod 755 /app/data /app/logs /var/log/nginx /var/lib/nginx /var/lib/nginx/logs /etc/nginx /run/nginx && \ 42 | chmod 644 /etc/nginx/nginx.conf && \ 43 | chmod +x /app/start.sh /app/entrypoint.sh 44 | 45 | # 移除USER指令,让entrypoint脚本处理用户切换 46 | EXPOSE 3000 47 | HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ 48 | CMD curl -f http://localhost/health || exit 1 49 | 50 | ENTRYPOINT ["dumb-init", "--", "/app/entrypoint.sh"] 51 | CMD ["/app/start.sh"] -------------------------------------------------------------------------------- /frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import Settings from '../views/Settings.vue' 3 | import Bills from '../views/Bills.vue' 4 | import Stats from '../views/Stats.vue' 5 | import Sync from '../views/Sync.vue' 6 | import Onboarding from '../views/Onboarding.vue' 7 | 8 | const routes = [ 9 | { 10 | path: '/', 11 | name: 'Home', 12 | component: Onboarding 13 | }, 14 | { 15 | path: '/onboarding', 16 | name: 'Onboarding', 17 | component: Onboarding 18 | }, 19 | { 20 | path: '/stats', 21 | name: 'Stats', 22 | component: Stats, 23 | meta: { requiresAuth: true } 24 | }, 25 | { 26 | path: '/bills', 27 | name: 'Bills', 28 | component: Bills, 29 | meta: { requiresAuth: true } 30 | }, 31 | { 32 | path: '/sync', 33 | name: 'Sync', 34 | component: Sync, 35 | meta: { requiresAuth: true } 36 | }, 37 | { 38 | path: '/settings', 39 | name: 'Settings', 40 | component: Settings, 41 | meta: { requiresAuth: true } 42 | } 43 | ] 44 | 45 | const router = createRouter({ 46 | history: createWebHistory(), 47 | routes 48 | }) 49 | 50 | import api from '../api' 51 | 52 | router.beforeEach(async (to, from, next) => { 53 | // 访问根路径时,检查数据库中是否有Token 54 | if (to.path === '/') { 55 | try { 56 | const result = await api.getToken() 57 | if (result.success && result.data && result.data.token) { 58 | // 数据库中有Token,保存到localStorage并跳转到统计页 59 | localStorage.setItem('api_token', result.data.token) 60 | return next('/stats') 61 | } else { 62 | // 数据库中没有Token,跳转到引导页 63 | return next('/onboarding') 64 | } 65 | } catch (error) { 66 | console.error('检查Token失败:', error) 67 | // 查询失败也跳转到引导页 68 | return next('/onboarding') 69 | } 70 | } 71 | 72 | // 访问 /onboarding 路径总是允许 73 | if (to.path === '/onboarding') { 74 | return next() 75 | } 76 | 77 | // 检查是否已配置智谱AI Token 78 | try { 79 | const result = await api.getToken() 80 | if (result.success && result.data && result.data.token) { 81 | // 已配置Token,允许访问 82 | return next() 83 | } 84 | } catch (error) { 85 | console.error('检查Token失败:', error) 86 | } 87 | 88 | // 没有配置Token,跳转到引导页 89 | next('/onboarding') 90 | }) 91 | 92 | export default router 93 | -------------------------------------------------------------------------------- /backend/src/middleware/auth.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | /** 4 | * 认证中间件 - 验证API Token 5 | * 从请求头中提取Token并验证其有效性 6 | * 如果验证失败,返回401状态码 7 | * 如果验证成功,将token附加到request对象并调用next() 8 | */ 9 | const authMiddleware = async (req, res, next) => { 10 | try { 11 | // 检查Authorization header是否存在 12 | const authHeader = req.headers.authorization; 13 | 14 | if (!authHeader || !authHeader.startsWith('Bearer ')) { 15 | return res.status(401).json({ 16 | success: false, 17 | message: '缺少有效的API Token' 18 | }); 19 | } 20 | 21 | // 提取token 22 | const token = authHeader.substring(7); 23 | 24 | // 验证token是否有效 25 | const isValidToken = await verifyToken(token); 26 | 27 | if (!isValidToken) { 28 | return res.status(401).json({ 29 | success: false, 30 | message: 'API Token无效或已过期' 31 | }); 32 | } 33 | 34 | // 将token附加到request对象,供后续中间件和路由使用 35 | req.token = token; 36 | next(); 37 | 38 | } catch (error) { 39 | console.error('认证中间件错误:', error); 40 | return res.status(500).json({ 41 | success: false, 42 | message: '服务器内部错误' 43 | }); 44 | } 45 | }; 46 | 47 | /** 48 | * 验证Token是否有效 49 | * @param {string} token - 要验证的token 50 | * @returns {Promise} 验证结果 51 | */ 52 | const verifyToken = async (token) => { 53 | try { 54 | // 使用提供的token调用智谱AI API进行验证 55 | const authHeader = `Bearer ${token}`; 56 | 57 | const response = await axios.get('https://bigmodel.cn/api/finance/expenseBill/expenseBillList', { 58 | params: { 59 | billingMonth: '2025-11', 60 | pageNum: 1, 61 | pageSize: 1 62 | }, 63 | headers: { 64 | 'Authorization': authHeader, 65 | 'Content-Type': 'application/json' 66 | }, 67 | timeout: 10000 68 | }); 69 | 70 | // 检查API返回状态 71 | if (response.data && response.data.code === 200) { 72 | return true; 73 | } else { 74 | return false; 75 | } 76 | } catch (error) { 77 | console.error('Token验证失败:', error); 78 | return false; 79 | } 80 | }; 81 | 82 | module.exports = { 83 | authMiddleware, 84 | verifyToken 85 | }; -------------------------------------------------------------------------------- /start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # start.sh - 统一启动脚本 3 | 4 | echo "启动智谱AI GLM Coding Plan 账单统计系统" 5 | 6 | # 检查Node.js是否安装 7 | if ! command -v node &> /dev/null; then 8 | echo "错误: Node.js 未安装,请先安装 Node.js" 9 | exit 1 10 | fi 11 | 12 | # 检查npm是否安装 13 | if ! command -v npm &> /dev/null; then 14 | echo "错误: npm 未安装,请先安装 npm" 15 | exit 1 16 | fi 17 | 18 | # 1. 检查并创建必要的目录 19 | mkdir -p data logs 20 | 21 | # 2. 检查数据库 22 | if [ ! -f "data/expense_bills.db" ]; then 23 | echo "初始化数据库..." 24 | cd backend 25 | if [ ! -d "node_modules" ]; then 26 | echo "安装后端依赖..." 27 | npm install 28 | fi 29 | npm run init-db 30 | cd .. 31 | fi 32 | 33 | # 3. 检查并安装后端依赖 34 | if [ ! -d "backend/node_modules" ]; then 35 | echo "安装后端依赖..." 36 | cd backend && npm install && cd .. 37 | fi 38 | 39 | # 4. 检查并安装前端依赖 40 | if [ ! -d "frontend/node_modules" ]; then 41 | echo "安装前端依赖..." 42 | cd frontend && npm install && cd .. 43 | fi 44 | 45 | # 清理可能存在的旧进程 46 | echo "清理可能存在的旧进程..." 47 | pkill -f "node.*backend" 2>/dev/null || true 48 | pkill -f "node.*frontend" 2>/dev/null || true 49 | sleep 2 50 | 51 | # 5. 启动后端(同时输出到控制台和日志文件) 52 | echo "启动后端服务..." 53 | cd backend 54 | # 使用 tee 命令同时输出到控制台和文件 55 | npm run dev 2>&1 | tee ../logs/backend.log & 56 | BACKEND_PID=$! 57 | cd .. 58 | 59 | # 6. 等待后端启动 60 | echo "等待后端服务启动..." 61 | sleep 5 62 | 63 | # 检查后端是否启动成功 64 | if ! curl -s -f http://localhost:7965/api/ > /dev/null 2>&1; then 65 | echo "警告: 后端服务可能还在启动中,继续启动前端..." 66 | fi 67 | 68 | # 7. 启动前端 69 | echo "启动前端服务..." 70 | cd frontend 71 | npm run dev > ../logs/frontend.log 2>&1 & 72 | FRONTEND_PID=$! 73 | cd .. 74 | 75 | # 8. 等待前端启动 76 | sleep 3 77 | 78 | echo "" 79 | echo "系统启动完成!" 80 | echo "前端地址: http://localhost:3000" 81 | echo "后端地址: http://localhost:7965" 82 | echo "后端日志: logs/backend.log" 83 | echo "前端日志: logs/frontend.log" 84 | echo "" 85 | echo "使用 Ctrl+C 停止所有服务" 86 | echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" 87 | 88 | # 保存PID到文件,方便停止脚本使用 89 | echo $BACKEND_PID > .backend.pid 90 | echo $FRONTEND_PID > .frontend.pid 91 | 92 | # 定义停止服务的函数 93 | cleanup() { 94 | echo "" 95 | echo "正在停止服务..." 96 | 97 | if [ -f .backend.pid ]; then 98 | BACKEND_PID=$(cat .backend.pid) 99 | kill $BACKEND_PID 2>/dev/null || true 100 | rm .backend.pid 101 | fi 102 | 103 | if [ -f .frontend.pid ]; then 104 | FRONTEND_PID=$(cat .frontend.pid) 105 | kill $FRONTEND_PID 2>/dev/null || true 106 | rm .frontend.pid 107 | fi 108 | 109 | # 强制清理可能残留的进程 110 | pkill -f "node.*backend" 2>/dev/null || true 111 | pkill -f "node.*frontend" 2>/dev/null || true 112 | 113 | echo "所有服务已停止" 114 | exit 0 115 | } 116 | 117 | # 捕获中断信号 118 | trap cleanup SIGINT SIGTERM 119 | 120 | # 等待用户退出 121 | wait $BACKEND_PID $FRONTEND_PID -------------------------------------------------------------------------------- /backend/src/routes/billRoutes.js: -------------------------------------------------------------------------------- 1 | // 账单相关API路由 2 | // 单用户系统,无需认证 3 | const express = require('express'); 4 | const router = express.Router(); 5 | const billController = require('../controllers/billController'); 6 | 7 | // 同步账单数据 8 | router.post('/sync', billController.syncBills.bind(billController)); 9 | 10 | // 获取账单列表 11 | router.get('/', billController.getBills.bind(billController)); 12 | 13 | // 获取产品列表 14 | router.get('/products', billController.getProducts.bind(billController)); 15 | 16 | // 获取expense_bills表记录总数 17 | router.get('/count', billController.getTotalCount.bind(billController)); 18 | 19 | // 获取账单统计信息 20 | router.get('/stats', billController.getStats.bind(billController)); 21 | 22 | // 获取同步状态 23 | router.get('/sync-status', billController.getSyncStatus.bind(billController)); 24 | 25 | // 保存同步历史记录 26 | router.post('/sync-history', billController.saveSyncHistory.bind(billController)); 27 | 28 | // 获取同步历史记录 29 | router.get('/sync-history', billController.getSyncHistory.bind(billController)); 30 | 31 | // 获取当前会员等级 32 | router.get('/current-membership-tier', billController.getCurrentMembershipTier.bind(billController)); 33 | 34 | // 获取API使用进度 35 | router.get('/api-usage-progress', billController.getApiUsageProgress.bind(billController)); 36 | 37 | // 获取Token使用量统计 38 | router.get('/token-usage-progress', billController.getTokenUsageProgress.bind(billController)); 39 | 40 | // 获取累计花费金额统计 41 | router.get('/total-cost-progress', billController.getTotalCostProgress.bind(billController)); 42 | 43 | // 获取每小时调用次数和Token数量 44 | router.get('/hourly-usage', billController.getHourlyUsage.bind(billController)); 45 | 46 | // 获取每天调用次数和Token数量 47 | router.get('/daily-usage', billController.getDailyUsage.bind(billController)); 48 | 49 | // 获取产品分布统计(按model_product_name分组,统计sum(api_usage)) 50 | router.get('/product-distribution', billController.getProductDistribution.bind(billController)); 51 | 52 | // 获取近1天API使用量统计 53 | router.get('/day-api-usage', billController.getDayApiUsage.bind(billController)); 54 | 55 | // 获取近1天Token使用量统计 56 | router.get('/day-token-usage', billController.getDayTokenUsage.bind(billController)); 57 | 58 | // 获取近1天累计花费金额统计 59 | router.get('/day-total-cost', billController.getDayTotalCost.bind(billController)); 60 | 61 | // 获取近1周API使用量统计 62 | router.get('/week-api-usage', billController.getWeekApiUsage.bind(billController)); 63 | 64 | // 获取近1周Token使用量统计 65 | router.get('/week-token-usage', billController.getWeekTokenUsage.bind(billController)); 66 | 67 | // 获取近1周累计花费金额统计 68 | router.get('/week-total-cost', billController.getWeekTotalCost.bind(billController)); 69 | 70 | // 获取近1月API使用量统计 71 | router.get('/month-api-usage', billController.getMonthApiUsage.bind(billController)); 72 | 73 | // 获取近1月Token使用量统计 74 | router.get('/month-token-usage', billController.getMonthTokenUsage.bind(billController)); 75 | 76 | // 获取近1月累计花费金额统计 77 | router.get('/month-total-cost', billController.getMonthTotalCost.bind(billController)); 78 | 79 | module.exports = router; 80 | -------------------------------------------------------------------------------- /docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # docker-entrypoint.sh - Docker容器入口点脚本 3 | # 支持动态用户ID权限调整 4 | 5 | log() { 6 | echo "[$(date '+%Y-%m-%d %H:%M:%S')] [entrypoint] $*" 7 | } 8 | 9 | # 检查并设置用户权限 10 | setup_user_permissions() { 11 | # 如果以root用户运行,检查是否需要切换用户 12 | if [ "$(id -u)" = "0" ]; then 13 | # 如果设置了PUID和PGID环境变量,则动态创建用户 14 | if [ -n "$PUID" ] && [ -n "$PGID" ]; then 15 | log "Setting up user with UID=${PUID} and GID=${PGID}" 16 | 17 | # 检查目标组是否已存在,如果不存在则创建 18 | if ! getent group $PGID >/dev/null 2>&1; then 19 | addgroup -g $PGID -S nodejs 20 | else 21 | # 组已存在,获取组名 22 | GROUP_NAME=$(getent group $PGID | cut -d: -f1) 23 | log "Using existing group: $GROUP_NAME (GID=$PGID)" 24 | fi 25 | 26 | # 检查目标用户是否已存在 27 | if ! id -u $PUID >/dev/null 2>&1; then 28 | # 用户不存在,创建新用户 29 | if [ "$GROUP_NAME" = "nodejs" ]; then 30 | adduser -S nodejs -u $PUID -G nodejs -h /app 31 | else 32 | adduser -S nodejs -u $PUID -G $GROUP_NAME -h /app 33 | fi 34 | else 35 | # 用户已存在,获取用户名 36 | USER_NAME=$(getent passwd $PUID | cut -d: -f1) 37 | log "Using existing user: $USER_NAME (UID=$PUID)" 38 | fi 39 | 40 | # 设置目录权限 41 | chown -R $PUID:$PGID /app /var/log/nginx /var/lib/nginx /etc/nginx /run/nginx 2>/dev/null || true 42 | 43 | # 如果存在数据目录,也要设置权限 44 | if [ -d "/app/data" ]; then 45 | chown -R $PUID:$PGID /app/data 46 | fi 47 | 48 | # 切换到指定用户执行命令 49 | log "Switching to user (UID=${PUID}, GID=${PGID})" 50 | if command -v su-exec >/dev/null 2>&1; then 51 | exec su-exec $PUID:$PGID "$@" 52 | else 53 | exec setpriv --reuid $PUID --regid $PGID --init-groups "$@" 54 | fi 55 | else 56 | # 没有设置PUID/PGID,使用默认的nodejs用户(UID=1001) 57 | log "Using default nodejs user (UID=1001)" 58 | 59 | # 确保nodejs用户存在 60 | if ! id nodejs >/dev/null 2>&1; then 61 | addgroup -g 1001 -S nodejs 62 | adduser -S nodejs -u 1001 -G nodejs -h /app 63 | chown -R nodejs:nodejs /app /var/log/nginx /var/lib/nginx /etc/nginx /run/nginx 2>/dev/null || true 64 | fi 65 | 66 | if command -v su-exec >/dev/null 2>&1; then 67 | exec su-exec nodejs "$@" 68 | else 69 | exec setpriv --reuid 1001 --regid 1001 --init-groups "$@" 70 | fi 71 | fi 72 | else 73 | # 非root用户运行,直接执行命令 74 | log "Running as current user (UID=$(id -u), GID=$(id -g))" 75 | exec "$@" 76 | fi 77 | } 78 | 79 | # 处理信号 80 | cleanup() { 81 | log "Received shutdown signal, cleaning up..." 82 | # 如果有子进程需要清理,在这里处理 83 | exit 0 84 | } 85 | 86 | trap cleanup SIGTERM SIGINT 87 | 88 | # 设置用户权限并执行命令 89 | setup_user_permissions "$@" -------------------------------------------------------------------------------- /DOCKER_PERMISSIONS.md: -------------------------------------------------------------------------------- 1 | # Docker权限配置说明 2 | 3 | ## 概述 4 | 5 | 本项目已优化Docker权限配置,支持动态用户ID,解决了在非特权容器环境下的权限问题。 6 | 7 | ## 新增功能 8 | 9 | ### 动态用户ID支持 10 | 11 | 现在支持通过环境变量动态设置容器内运行用户的UID和GID,避免主机与容器用户ID不匹配的问题。 12 | 13 | ### 环境变量 14 | 15 | - `PUID`: 设置容器内运行用户的UID(默认:1001) 16 | - `PGID`: 设置容器内运行用户的GID(默认:1001) 17 | 18 | ## 使用方法 19 | 20 | ### 1. 使用默认用户(UID=1001) 21 | 22 | ```bash 23 | docker run -d \ 24 | --name areyouok \ 25 | -p 3000:3000 \ 26 | -v $(pwd)/data:/app/data \ 27 | areyouok:latest 28 | ``` 29 | 30 | ### 2. 使用自定义用户ID 31 | 32 | ```bash 33 | docker run -d \ 34 | --name areyouok \ 35 | -p 3000:3000 \ 36 | -v $(pwd)/data:/app/data \ 37 | -e PUID=1000 \ 38 | -e PGID=1000 \ 39 | areyouok:latest 40 | ``` 41 | 42 | ### 3. 匹配主机用户 43 | 44 | ```bash 45 | # 获取当前用户的UID和GID 46 | HOST_UID=$(id -u) 47 | HOST_GID=$(id -g) 48 | 49 | docker run -d \ 50 | --name areyouok \ 51 | -p 3000:3000 \ 52 | -v $(pwd)/data:/app/data \ 53 | -e PUID=$HOST_UID \ 54 | -e PGID=$HOST_GID \ 55 | areyouok:latest 56 | ``` 57 | 58 | ### 4. Docker Compose配置 59 | 60 | ```yaml 61 | version: '3.8' 62 | services: 63 | areyouok: 64 | image: areyouok:latest 65 | ports: 66 | - "3000:3000" 67 | volumes: 68 | - ./data:/app/data 69 | - ./logs:/app/logs 70 | environment: 71 | - PUID=1000 # 根据需要修改 72 | - PGID=1000 # 根据需要修改 73 | restart: unless-stopped 74 | ``` 75 | 76 | ## 兼容性说明 77 | 78 | ### 向后兼容 79 | 80 | - **完全兼容**:现有的start.sh和stop.sh脚本无需任何修改 81 | - **无影响**:不设置PUID/PGID环境变量时,使用默认UID=1001 82 | - **平滑升级**:现有容器可以无缝升级到新版本 83 | 84 | ### 原有脚本保持不变 85 | 86 | - `docker-start.sh`: 启动脚本功能完全保留 87 | - `stop.sh`: 停止脚本功能完全保留 88 | - 所有原有的功能和配置都得到保留 89 | 90 | ## 权限处理逻辑 91 | 92 | 1. **默认情况**:如果未设置PUID/PGID,使用默认的nodejs用户(UID=1001) 93 | 2. **自定义情况**:如果设置了PUID/PGID,动态创建对应UID/GID的用户 94 | 3. **Root运行**:以root用户启动,entrypoint脚本负责用户切换 95 | 4. **权限修复**:自动修复关键目录的用户权限 96 | 97 | ## 解决的问题 98 | 99 | ### 1. 主机与容器用户ID不匹配 100 | - **问题**:容器内创建的文件在主机上显示为未知用户 101 | - **解决**:通过PUID/PGID匹配主机用户ID 102 | 103 | ### 2. 挂载目录权限问题 104 | - **问题**:容器无法写入挂载的主机目录 105 | - **解决**:动态调整用户权限,确保目录可访问 106 | 107 | ### 3. 数据持久化问题 108 | - **问题**:容器重启后数据文件权限混乱 109 | - **解决**:保持一致的用户权限设置 110 | 111 | ### 4. 多环境部署困难 112 | - **问题**:不同环境用户ID分配不同 113 | - **解决**:通过环境变量灵活配置 114 | 115 | ## 构建镜像 116 | 117 | ```bash 118 | # 构建镜像(使用默认UID=1001) 119 | docker build -t areyouok:latest . 120 | 121 | # 构建镜像(指定自定义UID) 122 | docker build --build-arg PUID=1000 --build-arg PGID=1000 -t areyouok:latest . 123 | ``` 124 | 125 | ## 故障排除 126 | 127 | ### 1. 权限被拒绝 128 | ```bash 129 | # 检查挂载目录权限 130 | ls -la ./data 131 | 132 | # 设置合适的权限 133 | sudo chown -R $USER:$USER ./data 134 | sudo chmod -R 755 ./data 135 | ``` 136 | 137 | ### 2. 数据库文件权限问题 138 | ```bash 139 | # 修复数据库文件权限 140 | sudo chown $USER:$USER ./data/expense_bills.db 141 | ``` 142 | 143 | ### 3. 日志写入失败 144 | ```bash 145 | # 确保日志目录有写权限 146 | mkdir -p ./logs 147 | chmod 755 ./logs 148 | ``` 149 | 150 | ## 最佳实践 151 | 152 | 1. **生产环境**:始终明确设置PUID/PGID环境变量 153 | 2. **开发环境**:使用当前用户ID匹配主机权限 154 | 3. **数据持久化**:确保挂载的目录有正确的权限 155 | 4. **监控日志**:检查entrypoint日志确认用户切换成功 -------------------------------------------------------------------------------- /backend/src/controllers/tokenController.js: -------------------------------------------------------------------------------- 1 | // Token控制器 2 | const Token = require('../database/models/Token'); 3 | const { verifyToken } = require('../middleware/auth'); 4 | 5 | class TokenController { 6 | // 验证Token 7 | static async verifyToken(req, res) { 8 | try { 9 | const { token } = req.body; 10 | 11 | if (!token) { 12 | return res.status(400).json({ 13 | success: false, 14 | message: 'Token不能为空' 15 | }); 16 | } 17 | 18 | const isValid = await verifyToken(token); 19 | 20 | if (isValid) { 21 | res.json({ 22 | success: true, 23 | message: 'Token验证成功' 24 | }); 25 | } else { 26 | res.status(401).json({ 27 | success: false, 28 | message: 'Token无效或已过期' 29 | }); 30 | } 31 | } catch (error) { 32 | console.error('验证Token失败:', error); 33 | res.status(500).json({ 34 | success: false, 35 | message: '验证Token失败: ' + error.message 36 | }); 37 | } 38 | } 39 | 40 | // 保存Token 41 | static async saveToken(req, res) { 42 | try { 43 | const { token } = req.body; 44 | 45 | if (!token) { 46 | return res.status(400).json({ 47 | success: false, 48 | message: 'Token不能为空' 49 | }); 50 | } 51 | 52 | const result = await Token.save(token); 53 | 54 | res.json({ 55 | success: true, 56 | message: 'Token保存成功', 57 | data: result 58 | }); 59 | } catch (error) { 60 | console.error('保存Token失败:', error); 61 | res.status(500).json({ 62 | success: false, 63 | message: '保存Token失败: ' + error.message 64 | }); 65 | } 66 | } 67 | 68 | // 获取Token 69 | static async getToken(req, res) { 70 | try { 71 | const token = await Token.get(); 72 | 73 | res.json({ 74 | success: true, 75 | data: token 76 | }); 77 | } catch (error) { 78 | console.error('获取Token失败:', error); 79 | res.status(500).json({ 80 | success: false, 81 | message: '获取Token失败: ' + error.message 82 | }); 83 | } 84 | } 85 | 86 | // 删除Token 87 | static async deleteToken(req, res) { 88 | try { 89 | await Token.delete(); 90 | 91 | res.json({ 92 | success: true, 93 | message: 'Token删除成功' 94 | }); 95 | } catch (error) { 96 | console.error('删除Token失败:', error); 97 | res.status(500).json({ 98 | success: false, 99 | message: '删除Token失败: ' + error.message 100 | }); 101 | } 102 | } 103 | } 104 | 105 | module.exports = TokenController; 106 | -------------------------------------------------------------------------------- /backend/src/index.js: -------------------------------------------------------------------------------- 1 | // 后端入口文件 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const dotenv = require('dotenv'); 5 | const path = require('path'); 6 | 7 | // 加载环境变量 8 | dotenv.config(); 9 | 10 | // 引入路由 11 | const billRoutes = require('./routes/billRoutes'); 12 | const tokenRoutes = require('./routes/tokenRoutes'); 13 | const autoSyncRoutes = require('./routes/autoSyncRoutes'); 14 | 15 | // 引入服务 16 | const autoSyncService = require('./services/autoSyncService'); 17 | 18 | // 创建Express应用 19 | const app = express(); 20 | const PORT = process.env.PORT || 7965; 21 | 22 | // 中间件 23 | app.use(cors()); // 允许跨域请求 24 | app.use(express.json()); // 解析JSON请求体 25 | app.use(express.urlencoded({ extended: true })); // 解析URL编码的请求体 26 | 27 | // API路由 28 | app.use('/api/bills', billRoutes); 29 | app.use('/api/tokens', tokenRoutes); 30 | app.use('/api/auto-sync', autoSyncRoutes); 31 | 32 | // 根路径 33 | app.get('/', (req, res) => { 34 | res.json({ 35 | message: '智谱AI GLM Coding Plan 账单统计 API', 36 | version: '1.0.0', 37 | endpoints: { 38 | sync: 'POST /api/bills/sync - 同步账单数据', 39 | list: 'GET /api/bills - 获取账单列表', 40 | products: 'GET /api/bills/products - 获取产品列表', 41 | stats: 'GET /api/bills/stats - 获取统计信息', 42 | status: 'GET /api/bills/sync-status - 获取同步状态', 43 | tokenSave: 'POST /api/tokens/save - 保存Token', 44 | tokenGet: 'GET /api/tokens/get - 获取Token' 45 | } 46 | }); 47 | }); 48 | 49 | // 404处理 50 | app.use((req, res) => { 51 | res.status(404).json({ 52 | success: false, 53 | message: 'API路径不存在' 54 | }); 55 | }); 56 | 57 | // 错误处理中间件 58 | app.use((err, req, res, next) => { 59 | console.error('服务器错误:', err); 60 | res.status(500).json({ 61 | success: false, 62 | message: '服务器内部错误', 63 | error: process.env.NODE_ENV === 'development' ? err.message : undefined 64 | }); 65 | }); 66 | 67 | // 启动服务器 68 | app.listen(PORT, () => { 69 | console.log(`=================================`); 70 | console.log(` 智谱AI GLM Coding Plan 账单统计 API`); 71 | console.log(` 端口: ${PORT}`); 72 | console.log(` 环境: ${process.env.NODE_ENV || 'development'}`); 73 | console.log(`=================================`); 74 | console.log(`\n可用接口:`); 75 | console.log(` POST /api/bills/sync - 同步账单数据`); 76 | console.log(` GET /api/bills - 获取账单列表`); 77 | console.log(` GET /api/bills/products - 获取产品列表`); 78 | console.log(` GET /api/bills/stats - 获取统计信息`); 79 | console.log(` GET /api/bills/sync-status - 获取同步状态`); 80 | console.log(` POST /api/tokens/save - 保存Token`); 81 | console.log(` GET /api/tokens/get - 获取Token`); 82 | console.log(` GET /api/auto-sync/config - 获取自动同步配置`); 83 | console.log(` POST /api/auto-sync/config - 保存自动同步配置`); 84 | console.log(` POST /api/auto-sync/trigger - 触发自动同步`); 85 | console.log(` POST /api/auto-sync/stop - 停止自动同步`); 86 | console.log(`\n=================================`); 87 | 88 | // 启动自动同步服务 89 | autoSyncService.start(); 90 | }); 91 | 92 | // 优雅关闭 93 | process.on('SIGTERM', () => { 94 | shutdown(); 95 | }); 96 | 97 | process.on('SIGINT', () => { 98 | console.log('\n收到SIGINT信号,正在关闭服务器...'); 99 | shutdown(); 100 | }); 101 | 102 | /** 103 | * 优雅关闭服务器 104 | */ 105 | function shutdown() { 106 | console.log('正在停止自动同步服务...'); 107 | autoSyncService.stop(); 108 | 109 | setTimeout(() => { 110 | console.log('服务器已关闭'); 111 | process.exit(0); 112 | }, 1000); 113 | } 114 | -------------------------------------------------------------------------------- /frontend/src/components/StatCardVertical.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 60 | 61 | 158 | -------------------------------------------------------------------------------- /backend/src/database/models/MembershipTierLimit.js: -------------------------------------------------------------------------------- 1 | // 会员等级调用次数限制模型 2 | const db = require('../connection'); 3 | 4 | class MembershipTierLimit { 5 | /** 6 | * 根据会员等级名称获取调用次数限制 7 | * @param {string} tierName - 会员等级名称 8 | * @returns {Promise} 包含call_limit和period_hours的对象 9 | */ 10 | static getLimitByTierName(tierName) { 11 | return new Promise((resolve, reject) => { 12 | db.get( 13 | 'SELECT call_limit, period_hours FROM membership_tier_limits WHERE tier_name = ?', 14 | [tierName], 15 | (err, row) => { 16 | if (err) { 17 | return reject(err); 18 | } 19 | if (!row) { 20 | // 如果没有找到,返回默认值(GLM Coding Pro) 21 | resolve({ 22 | call_limit: 600, 23 | period_hours: 5 24 | }); 25 | } else { 26 | resolve(row); 27 | } 28 | } 29 | ); 30 | }); 31 | } 32 | 33 | /** 34 | * 智能匹配会员等级名称 35 | * @param {string} tokenResourceName - 资源名称(如:"GLM Coding Lite - 包季计划") 36 | * @returns {Promise} 包含call_limit和period_hours的对象 37 | */ 38 | static getLimitByTokenResourceName(tokenResourceName) { 39 | return new Promise((resolve, reject) => { 40 | // 获取所有会员等级列表进行模糊匹配 41 | db.all( 42 | 'SELECT tier_name, call_limit, period_hours FROM membership_tier_limits ORDER BY call_limit', 43 | [], 44 | (err, rows) => { 45 | if (err) { 46 | return reject(err); 47 | } 48 | 49 | // 尝试精确匹配 50 | for (const row of rows) { 51 | if (tokenResourceName === row.tier_name) { 52 | resolve(row); 53 | return; 54 | } 55 | } 56 | 57 | // 尝试部分匹配(包含关系) 58 | for (const row of rows) { 59 | if (tokenResourceName.includes(row.tier_name)) { 60 | resolve(row); 61 | return; 62 | } 63 | } 64 | 65 | // 如果没有找到,返回默认值(GLM Coding Pro) 66 | const defaultTier = rows.find(r => r.tier_name === 'GLM Coding Pro') || rows[0]; 67 | resolve(defaultTier ? { 68 | call_limit: defaultTier.call_limit, 69 | period_hours: defaultTier.period_hours 70 | } : { 71 | call_limit: 10, 72 | period_hours: 5 73 | }); 74 | } 75 | ); 76 | }); 77 | } 78 | 79 | /** 80 | * 获取所有会员等级限制 81 | * @returns {Promise} 所有会员等级限制列表 82 | */ 83 | static getAllLimits() { 84 | return new Promise((resolve, reject) => { 85 | db.all( 86 | 'SELECT tier_name, call_limit, period_hours FROM membership_tier_limits ORDER BY call_limit', 87 | [], 88 | (err, rows) => { 89 | if (err) { 90 | return reject(err); 91 | } 92 | resolve(rows); 93 | } 94 | ); 95 | }); 96 | } 97 | } 98 | 99 | module.exports = MembershipTierLimit; 100 | -------------------------------------------------------------------------------- /DOCKER.md: -------------------------------------------------------------------------------- 1 | # Docker 部署指南 2 | 3 | 智谱AI GLM Coding Plan 账单统计系统 Docker 容器化部署文档 4 | 5 | ## 🚀 快速开始 6 | 7 | ### 自动化脚本(推荐) 8 | 9 | ```bash 10 | # 构建默认版本 11 | ./docker-build.sh 12 | 13 | # 构建指定版本 14 | ./docker-build.sh 1.0.0 15 | 16 | # 查看状态 17 | ./docker-build.sh status 18 | 19 | # 查看日志 20 | ./docker-build.sh logs 21 | 22 | # 停止服务 23 | ./docker-build.sh stop 24 | ``` 25 | 26 | ### Docker Compose 27 | 28 | ```bash 29 | # 启动服务 30 | docker-compose up -d 31 | 32 | # 查看状态 33 | docker-compose ps 34 | 35 | # 查看日志 36 | docker-compose logs -f 37 | 38 | # 停止服务 39 | docker-compose down 40 | ``` 41 | 42 | ## 🏗️ 构建和运行 43 | 44 | ### 手动构建 45 | 46 | ```bash 47 | # 构建镜像 48 | docker build -t areyouok-app:latest . 49 | 50 | # 运行容器 51 | docker run -d \ 52 | --name areyouok-app \ 53 | --restart unless-stopped \ 54 | -p 3000:3000 \ 55 | -v $(pwd)/data:/app/data:rw \ 56 | -v $(pwd)/logs:/app/logs:rw \ 57 | areyouok-app:latest 58 | ``` 59 | 60 | ### 版本管理 61 | 62 | ```bash 63 | # 构建指定版本 64 | docker build -t areyouok-app:1.0.0 . 65 | 66 | # 运行指定版本 67 | docker run -d --name areyouok-app -p 3000:3000 \ 68 | -v $(pwd)/data:/app/data:rw \ 69 | -v $(pwd)/logs:/app/logs:rw \ 70 | areyouok-app:1.0.0 71 | 72 | # 查看镜像版本 73 | docker images | grep areyouok-app 74 | ``` 75 | 76 | ## ⚙️ 配置说明 77 | 78 | ### 环境变量 79 | 80 | | 变量名 | 默认值 | 说明 | 81 | |--------|--------|------| 82 | | `NODE_ENV` | `production` | 运行环境 | 83 | | `PORT` | `7965` | 后端API端口(容器内部) | 84 | | `TZ` | `Asia/Shanghai` | 时区设置 | 85 | 86 | ### 数据卷 87 | 88 | ```bash 89 | # SQLite 数据库持久化 90 | -v $(pwd)/data:/app/data:rw 91 | 92 | # 日志文件持久化 93 | -v $(pwd)/logs:/app/logs:rw 94 | ``` 95 | 96 | ### 端口说明 97 | 98 | - `3000:3000` - 外部访问端口(Nginx前端服务) 99 | - `7965` - 后端API端口(仅容器内部,通过Nginx代理访问) 100 | 101 | ## 🔍 健康检查 102 | 103 | ```bash 104 | # 查看容器状态 105 | docker ps --format "table {{.Names}}\t{{.Status}}" 106 | 107 | # 手动健康检查 108 | curl http://localhost:3000/health 109 | curl http://localhost:3000/api/ 110 | ``` 111 | 112 | ## 📝 日志管理 113 | 114 | ```bash 115 | # 查看容器日志 116 | docker logs areyouok-app 117 | docker logs -f areyouok-app 118 | 119 | # 查看应用日志文件 120 | tail -f logs/backend.log 121 | tail -f logs/nginx.log 122 | ``` 123 | 124 | ## 💾 数据备份 125 | 126 | ```bash 127 | # 手动备份 128 | docker run --rm \ 129 | -v $(pwd)/data:/data:ro \ 130 | -v $(pwd)/backups:/backups:rw \ 131 | alpine:latest \ 132 | tar -czf /backups/backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /data expense_bills.db 133 | ``` 134 | 135 | ## 🔧 故障排除 136 | 137 | ### 常见问题 138 | 139 | #### 容器启动失败 140 | ```bash 141 | # 查看启动日志 142 | docker logs areyouok-app 143 | 144 | # 重新构建镜像 145 | docker build --no-cache -t areyouok-app . 146 | ``` 147 | 148 | #### 端口冲突 149 | ```bash 150 | # 检查端口占用 151 | lsof -i :3000 152 | 153 | # 使用其他端口 154 | docker run -d --name areyouok-app -p 8080:3000 areyouok-app 155 | ``` 156 | 157 | #### 数据库权限问题 158 | ```bash 159 | # 修复权限 160 | sudo chown -R 1001:1001 data/ 161 | ``` 162 | 163 | #### 调试模式 164 | ```bash 165 | # 进入容器 166 | docker exec -it areyouok-app /bin/sh 167 | 168 | # 查看进程状态 169 | docker exec areyouok-app ps aux 170 | ``` 171 | 172 | ## 🔄 更新升级 173 | 174 | ```bash 175 | # 停止旧容器 176 | docker stop areyouok-app 177 | docker rm areyouok-app 178 | 179 | # 重新构建镜像 180 | docker build --no-cache -t areyouok-app . 181 | 182 | # 启动新容器 183 | docker run -d \ 184 | --name areyouok-app \ 185 | --restart unless-stopped \ 186 | -p 3000:3000 \ 187 | -v $(pwd)/data:/app/data:rw \ 188 | -v $(pwd)/logs:/app/logs:rw \ 189 | areyouok-app 190 | ``` 191 | 192 | ## 📞 访问地址 193 | 194 | 部署成功后的访问地址: 195 | 196 | - **前端界面**: http://localhost:3000 197 | - **后端API**: http://localhost:3000/api/ 198 | - **健康检查**: http://localhost:3000/health 199 | 200 | ## 📋 部署检查清单 201 | 202 | ### 部署前检查 203 | - [ ] Docker和Docker Compose已安装 204 | - [ ] 项目源代码已克隆 205 | - [ ] 端口3000未被占用 206 | - [ ] data和logs目录有写权限 207 | 208 | ### 部署后验证 209 | - [ ] 容器启动成功 210 | - [ ] 健康检查通过 211 | - [ ] 前端页面可访问 212 | - [ ] 后端API可访问 213 | - [ ] 数据库初始化完成 214 | 215 | --- 216 | 217 | **注意**: 首次运行时,系统会自动初始化SQLite数据库。请确保 `data/` 和 `logs/` 目录具有适当的写入权限。 -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | const api = axios.create({ 4 | baseURL: '/api', 5 | timeout: 30000 6 | }) 7 | 8 | // 单用户系统,无需在请求头中添加认证信息 9 | // 智谱AI Token已存储在系统数据库中,后端自动从数据库读取 10 | 11 | api.interceptors.response.use( 12 | (response) => { 13 | return response.data 14 | }, 15 | (error) => { 16 | if (error.response) { 17 | const message = error.response.data?.message || error.message 18 | return Promise.reject(new Error(message)) 19 | } 20 | return Promise.reject(error) 21 | } 22 | ) 23 | 24 | export default { 25 | syncBills(billingMonth, type = 'full') { 26 | return api.post('/bills/sync', { billingMonth, type }) 27 | }, 28 | 29 | getBills(params) { 30 | return api.get('/bills', { params }) 31 | }, 32 | 33 | getProducts() { 34 | return api.get('/bills/products') 35 | }, 36 | 37 | getBillsCount() { 38 | return api.get('/bills/count') 39 | }, 40 | 41 | getCurrentMembershipTier() { 42 | return api.get('/bills/current-membership-tier') 43 | }, 44 | 45 | getApiUsageProgress() { 46 | return api.get('/bills/api-usage-progress') 47 | }, 48 | 49 | getTokenUsageProgress() { 50 | return api.get('/bills/token-usage-progress') 51 | }, 52 | 53 | getTotalCostProgress() { 54 | return api.get('/bills/total-cost-progress') 55 | }, 56 | 57 | getAutoSyncConfig() { 58 | return api.get('/auto-sync/config') 59 | }, 60 | 61 | getStats(period = '5h') { 62 | return api.get('/bills/stats', { params: { period } }) 63 | }, 64 | 65 | getSyncStatus() { 66 | return api.get('/bills/sync-status') 67 | }, 68 | 69 | // 保存同步历史记录 70 | saveSyncHistory(historyData) { 71 | return api.post('/bills/sync-history', historyData) 72 | }, 73 | 74 | // 获取同步历史记录 75 | getSyncHistory(syncType, limit = 10, page = 1) { 76 | const offset = (page - 1) * limit 77 | return api.get('/bills/sync-history', { 78 | params: { sync_type: syncType, limit, offset } 79 | }) 80 | }, 81 | 82 | // Token管理 83 | verifyToken(token) { 84 | return api.post('/tokens/verify', { token }) 85 | }, 86 | 87 | saveToken(token) { 88 | return api.post('/tokens/save', { token }) 89 | }, 90 | 91 | getToken() { 92 | return api.get('/tokens/get') 93 | }, 94 | 95 | deleteToken() { 96 | return api.delete('/tokens/delete') 97 | }, 98 | 99 | // 自动同步相关 100 | getAutoSyncConfig() { 101 | return api.get('/auto-sync/config') 102 | }, 103 | 104 | saveAutoSyncConfig(config) { 105 | return api.post('/auto-sync/config', config) 106 | }, 107 | 108 | triggerAutoSync() { 109 | return api.post('/auto-sync/trigger') 110 | }, 111 | 112 | stopAutoSync() { 113 | return api.post('/auto-sync/stop') 114 | }, 115 | 116 | // 获取每小时调用次数和Token数量 117 | getHourlyUsage(hours = 5) { 118 | return api.get('/bills/hourly-usage', { params: { hours } }) 119 | }, 120 | 121 | // 获取每天调用次数和Token数量 122 | getDailyUsage(days = 7) { 123 | return api.get('/bills/daily-usage', { params: { days } }) 124 | }, 125 | 126 | // 获取近30天每天调用次数和Token数量 127 | getMonthlyUsage() { 128 | return api.get('/bills/daily-usage', { params: { days: 30 } }) 129 | }, 130 | 131 | // 获取产品分布统计 132 | getProductDistribution(hours = 5) { 133 | return api.get('/bills/product-distribution', { params: { hours } }) 134 | }, 135 | 136 | // 获取近1天API使用量统计 137 | getDayApiUsage() { 138 | return api.get('/bills/day-api-usage') 139 | }, 140 | 141 | // 获取近1天Token使用量统计 142 | getDayTokenUsage() { 143 | return api.get('/bills/day-token-usage') 144 | }, 145 | 146 | // 获取近1天累计花费金额统计 147 | getDayTotalCost() { 148 | return api.get('/bills/day-total-cost') 149 | }, 150 | 151 | // 获取近1周API使用量统计 152 | getWeekApiUsage() { 153 | return api.get('/bills/week-api-usage') 154 | }, 155 | 156 | // 获取近1周Token使用量统计 157 | getWeekTokenUsage() { 158 | return api.get('/bills/week-token-usage') 159 | }, 160 | 161 | // 获取近1周累计花费金额统计 162 | getWeekTotalCost() { 163 | return api.get('/bills/week-total-cost') 164 | }, 165 | 166 | // 获取近1月API使用量统计 167 | getMonthApiUsage() { 168 | return api.get('/bills/month-api-usage') 169 | }, 170 | 171 | // 获取近1月Token使用量统计 172 | getMonthTokenUsage() { 173 | return api.get('/bills/month-token-usage') 174 | }, 175 | 176 | // 获取近1月累计花费金额统计 177 | getMonthTotalCost() { 178 | return api.get('/bills/month-total-cost') 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /backend/src/services/autoSyncService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 自动同步服务 3 | * 负责定时检查和触发自动同步 4 | * 遵循核心开发原则:KISS、单一职责、异常处理 5 | */ 6 | 7 | const AutoSyncConfig = require('../database/models/AutoSyncConfig'); 8 | const syncService = require('./syncService'); 9 | 10 | class AutoSyncService { 11 | constructor() { 12 | this.intervalId = null; // 定时任务ID 13 | this.isRunning = false; // 是否正在运行 14 | this.isProcessing = false; // 是否正在处理同步(防止并发) 15 | this.checkInterval = 1000; // 检查间隔:1秒(更精确,符合配置频率) 16 | } 17 | 18 | /** 19 | * 启动自动同步服务 20 | * @param {number} interval - 检查间隔(毫秒),默认1秒 21 | */ 22 | start(interval = 1000) { 23 | if (this.isRunning) { 24 | console.log('自动同步服务已在运行'); 25 | return; 26 | } 27 | 28 | this.checkInterval = interval; 29 | this.isRunning = true; 30 | 31 | console.log(`自动同步服务已启动,检查间隔: ${interval / 1000}秒`); 32 | 33 | // 启动定时检查 34 | this.intervalId = setInterval(() => { 35 | this.checkAndTriggerSync(); 36 | }, this.checkInterval); 37 | } 38 | 39 | /** 40 | * 停止自动同步服务 41 | */ 42 | stop() { 43 | if (!this.isRunning) { 44 | console.log('自动同步服务未运行'); 45 | return; 46 | } 47 | 48 | if (this.intervalId) { 49 | clearInterval(this.intervalId); 50 | this.intervalId = null; 51 | } 52 | 53 | this.isRunning = false; 54 | this.isProcessing = false; 55 | 56 | console.log('自动同步服务已停止'); 57 | } 58 | 59 | /** 60 | * 检查并触发同步 61 | * 单例模式:避免并发处理 62 | */ 63 | async checkAndTriggerSync() { 64 | // 如果正在处理中,跳过本次检查 65 | if (this.isProcessing) { 66 | return; 67 | } 68 | 69 | try { 70 | this.isProcessing = true; 71 | 72 | // 获取所有需要同步的配置 73 | const dueConfigs = await AutoSyncConfig.getDueConfigs(); 74 | 75 | if (dueConfigs.length === 0) { 76 | this.isProcessing = false; 77 | return; 78 | } 79 | 80 | console.log(`\n发现 ${dueConfigs.length} 个配置需要同步`); 81 | 82 | // 逐个处理(串行处理,避免并发) 83 | for (const config of dueConfigs) { 84 | await this.processSync(config); 85 | } 86 | 87 | } catch (error) { 88 | console.error('自动同步检查失败:', error.message); 89 | } finally { 90 | this.isProcessing = false; 91 | } 92 | } 93 | 94 | /** 95 | * 处理单个同步任务 96 | * @param {Object} config - 同步配置 97 | */ 98 | async processSync(config) { 99 | const startTime = Date.now(); // 记录开始时间 100 | 101 | try { 102 | console.log(`\n开始自动同步 (配置ID: ${config.id})`); 103 | 104 | // 获取当前月份 105 | const now = new Date(); 106 | const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; 107 | 108 | // 触发增量同步(历史记录保存由 syncService 处理) 109 | const result = await syncService.incrementalSync(currentMonth); 110 | 111 | // 记录结果 112 | console.log(`自动同步完成:`, { 113 | synced: result.synced || 0, 114 | failed: result.failed || 0, 115 | total: result.total || 0 116 | }); 117 | 118 | // 更新最后同步时间 119 | if (config.id) { 120 | await AutoSyncConfig.updateLastSyncTime(config.id); 121 | } 122 | 123 | // 计算并更新下次同步时间 124 | const nextSyncTime = AutoSyncConfig.calculateNextSyncTime(config.frequency_seconds); 125 | if (config.id) { 126 | await AutoSyncConfig.updateNextSyncTime(config.id, nextSyncTime); 127 | console.log(`下次同步时间: ${nextSyncTime}`); 128 | } 129 | 130 | } catch (err) { 131 | console.error(`自动同步失败 (配置ID: ${config.id}):`, err.message); 132 | 133 | // 更新最后同步时间(即使失败) 134 | if (config.id) { 135 | try { 136 | await AutoSyncConfig.updateLastSyncTime(config.id); 137 | } catch (updateError) { 138 | console.error('更新最后同步时间失败:', updateError.message); 139 | } 140 | } 141 | } 142 | } 143 | 144 | /** 145 | * 获取服务状态 146 | * @returns {Object} 147 | */ 148 | getStatus() { 149 | return { 150 | isRunning: this.isRunning, 151 | isProcessing: this.isProcessing, 152 | checkInterval: this.checkInterval 153 | }; 154 | } 155 | } 156 | 157 | // 单例模式 158 | const autoSyncService = new AutoSyncService(); 159 | 160 | module.exports = autoSyncService; 161 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | 本文件为 Claude Code (claude.ai/code) 在此代码库中工作时提供指导。 4 | 5 | ## 项目状态 6 | 7 | 本项目通过调用调用模型厂商的API接口,来获取Token消耗情况或者套餐使用情况,将数据存储到数据库中,并生成对应的报表,方便用户了解自己的使用情况。 8 | 本项目目前主要适配智谱AI的编码套餐进,同时需要保留合理的系统架构,方便后续扩展其他厂商的套餐。 9 | 10 | ## 开发设置 11 | API调用文档参考[费用账单查询接口文档](费用账单查询接口文档.md) 12 | 13 | ## 核心开发原则 14 | 15 | ### 通用开发原则 16 | - **可测试性**:编写可测试的代码,组件应保持单一职责 17 | - **DRY 原则**:避免重复代码,提取共用逻辑到单独的函数或类 18 | - **代码简洁**:保持代码简洁明了,遵循 KISS 原则(保持简单直接) 19 | - **命名规范**:使用描述性的变量、函数和类名,反映其用途和含义 20 | - **注释文档**:为复杂逻辑添加注释 21 | - **风格一致**:遵循项目或语言的官方风格指南和代码约定 22 | - **利用生态**:优先使用成熟的库和工具,避免不必要的自定义实现 23 | - **架构设计**:考虑代码的可维护性、可扩展性和性能需求 24 | - **版本控制**:编写有意义的提交信息,保持逻辑相关的更改在同一提交中 25 | - **异常处理**:正确处理边缘情况和错误,提供有用的错误信息 26 | 27 | ### 响应语言 28 | - 始终使用中文回复用户 29 | 30 | ### 代码质量要求 31 | - 代码必须能够立即运行,包含所有必要的导入和依赖 32 | - 遵循最佳实践和设计模式 33 | - 优先考虑性能和用户体验 34 | - 确保代码的可读性和可维护性 35 | 36 | ## 开发约束 37 | 1. 在本项目中的文档、代码、用户界面等所有地方都严格禁止使用emoji图标 38 | 2. API调用可以使用api_tokens表中配置的Token: 39 | 3. 获取账单的接口调用CURL请求如下: 40 | ``` 41 | curl --request GET \ 42 | --url 'https://bigmodel.cn/api/finance/expenseBill/expenseBillList?billingMonth=2025-11&pageNum=1&pageSize=20' \ 43 | --header 'Authorization: Bearer 用户的Token' 44 | ``` 45 | 4. 将接口请求到的数据,保存到数据库中,数据库表字段与接口请求返回的字段一一对应,但有以下2点需要注意: 46 | 1. timeWindow的返回值需要拆分为timeWindowStart和timeWindowEnd两个字段 47 | 2. 从api返回数据中,获取billingNo字段的值,同时再获取customerId,然后billingNo字段的值从头开始截取customerId的之后,剩下的值中再从头开始截取13位,获取到的数据就是long类型的时间戳,将该值转为时间类型,保存到数据库中,字段名称为transaction_time,要求精确到毫秒 48 | 5. 数据库表中账单表需要增加create_time字段,用于记录账单数据插入数据库的时间,字段类型为datetime 49 | 50 | ## 技术选型 51 | vue3 + element-plus + nodejs + sqlite3 52 | 53 | ## 项目结构 54 | 55 | 基于技术选型(vue3 + element-plus + nodejs + sqlite3),建议采用前后端分离架构: 56 | 57 | ``` 58 | areYouOk/ 59 | ├── backend/ # Node.js 后端 60 | │ ├── src/ 61 | │ │ ├── controllers/ # 控制器 62 | │ │ ├── services/ # 业务逻辑层 63 | │ │ │ └── apiService.js # API 调用服务 64 | │ │ ├── models/ # 数据模型 65 | │ │ │ ├── Bill.js # 账单数据模型 66 | │ │ │ └── SyncHistory.js # 同步历史数据模型 67 | │ │ ├── database/ # 数据库相关 68 | │ │ │ ├── schema.sql # 数据库模式定义(包含所有表和索引) 69 | │ │ │ └── connection.js # 数据库连接配置 70 | │ │ ├── routes/ # 路由 71 | │ │ └── utils/ # 工具函数 72 | │ └── package.json 73 | ├── frontend/ # Vue3 前端 74 | │ ├── src/ 75 | │ │ ├── views/ # 页面组件 76 | │ │ ├── components/ # 公共组件 77 | │ │ ├── api/ # API 接口 78 | │ │ ├── store/ # 状态管理 79 | │ │ └── utils/ # 工具函数 80 | │ └── package.json 81 | └── docs/ # 文档 82 | ├── 费用账单查询接口文档.md 83 | └── prd.md 84 | ``` 85 | 86 | 核心模块: 87 | - **数据同步模块**:调用智谱AI接口,同步账单数据到本地数据库 88 | - 支持增量同步和全量同步两种模式 89 | - 异步同步机制,避免超时问题 90 | - 实时进度监控(分阶段进度条显示) 91 | - **同步历史管理模块**:记录和管理所有数据同步操作 92 | - 自动记录同步结果(成功/失败/异常) 93 | - 按类型分类展示(增量/全量) 94 | - 支持历史记录查询和统计 95 | - **统计模块**:计算近5小时调用次数、总token数量等关键指标 96 | - **展示模块**:Vue3 + Element Plus 构建的数据可视化界面 97 | 98 | ## 架构 99 | 100 | ### 整体架构 101 | 采用前后端分离的 MVC 架构模式: 102 | 103 | ``` 104 | ┌─────────────────┐ 105 | │ Vue3 前端 │ 106 | │ (展示层) │ 107 | └────────┬────────┘ 108 | │ HTTP/REST API 109 | ┌────────▼────────┐ 110 | │ Node.js 后端 │ 111 | │ (业务逻辑层) │ 112 | └────────┬────────┘ 113 | │ SQL 114 | ┌────────▼────────┐ 115 | │ SQLite3 │ 116 | │ (数据存储层) │ 117 | └─────────────────┘ 118 | ``` 119 | 120 | ### 分层设计 121 | 122 | 1. **展示层(Vue3 + Element Plus)** 123 | - 用户界面展示 124 | - 数据可视化(图表、表格) 125 | - 实时数据更新 126 | 127 | 2. **业务逻辑层(Node.js)** 128 | - **API调用服务**:封装智谱AI接口调用逻辑 129 | - **数据同步服务**:全量/增量同步账单数据 130 | - **统计计算服务**:计算调用次数、Token用量等指标 131 | - **数据转换服务**:处理 timeWindow 拆分、时间戳转换等 132 | 133 | 3. **数据存储层(SQLite3)** 134 | - 账单明细表(按接口字段设计) 135 | - 索引优化查询性能 136 | 137 | ### 数据流 138 | 1. 定时任务触发数据同步 139 | 2. 调用智谱AI API 获取账单数据 140 | 3. 数据清洗与转换(timeWindow拆分、transaction_time提取) 141 | 4. 存储到 SQLite 数据库 142 | 5. 前端实时查询统计结果并展示 143 | 144 | ### 关键技术注意事项 145 | 146 | 1. **数据转换**: 147 | - `timeWindow` 字段需拆分为 `timeWindowStart` 和 `timeWindowEnd` 148 | - `billingNo`的值中截掉`customerId`的值,再从头开始提取13位时间戳,转换为 `transaction_time`(精确到毫秒) 149 | - 数据库需增加 `create_time` 字段(datetime类型) 150 | 151 | 2. **API 调用**: 152 | - 使用提供的 Bearer Token 进行认证 153 | - 支持分页查询(`pageNum` 和 `pageSize`) 154 | - 按账单月份查询(`billingMonth` 格式:YYYY-MM) 155 | 156 | 3. **数据库设计**: 157 | - SQLite3 表字段与API返回字段一一对应 158 | - 为常用查询字段建立索引 159 | - 考虑时间范围查询的索引优化 160 | - 所有创建表、索引的脚本,需要在 `backend/src/database/schema.sql` 中定义 161 | - 所有表结构,需要增加字段注释,说明字段含义 162 | 163 | 4. **前端开发**: 164 | - 严格禁止使用 emoji 图标 165 | - Vue3 + Element Plus 实现数据可视化 166 | - 实时更新统计数据 167 | - 使用莫兰迪色系(#4D6782主色调) 168 | 169 | 5. **扩展性考虑**: 170 | - API调用服务需抽象化,便于适配其他厂商 171 | - 统计逻辑模块化,支持不同统计维度 172 | - 预留多租户支持(当前主要针对智谱AI) 173 | 174 | ### 验收标准 175 | - 数据同步准确率:100% 176 | - 统计结果实时更新,误差:0% 177 | - 系统可用性:≥99.9% 178 | - 查询响应时间:<3秒 -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | # Nginx configuration for AreYouOk AI billing system 2 | # Optimized for production deployment 3 | 4 | events { 5 | worker_connections 1024; 6 | use epoll; 7 | multi_accept on; 8 | } 9 | 10 | http { 11 | include /etc/nginx/mime.types; 12 | default_type application/octet-stream; 13 | 14 | # Logging format 15 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 16 | '$status $body_bytes_sent "$http_referer" ' 17 | '"$http_user_agent" "$http_x_forwarded_for" ' 18 | 'rt=$request_time uct="$upstream_connect_time" ' 19 | 'uht="$upstream_header_time" urt="$upstream_response_time"'; 20 | 21 | access_log /var/log/nginx/access.log main; 22 | error_log /var/log/nginx/error.log warn; 23 | 24 | # Performance optimizations 25 | sendfile on; 26 | tcp_nopush on; 27 | tcp_nodelay on; 28 | keepalive_timeout 65; 29 | types_hash_max_size 2048; 30 | server_tokens off; 31 | 32 | # Gzip compression 33 | gzip on; 34 | gzip_vary on; 35 | gzip_min_length 1024; 36 | gzip_proxied any; 37 | gzip_comp_level 6; 38 | gzip_types 39 | application/atom+xml 40 | application/javascript 41 | application/json 42 | application/ld+json 43 | application/manifest+json 44 | application/rss+xml 45 | application/vnd.geo+json 46 | application/vnd.ms-fontobject 47 | application/x-font-ttf 48 | application/x-web-app-manifest+json 49 | application/xhtml+xml 50 | application/xml 51 | font/opentype 52 | image/bmp 53 | image/svg+xml 54 | image/x-icon 55 | text/cache-manifest 56 | text/css 57 | text/plain 58 | text/vcard 59 | text/vnd.rim.location.xloc 60 | text/vtt 61 | text/x-component 62 | text/x-cross-domain-policy; 63 | 64 | # Rate limiting for API endpoints 65 | limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; 66 | 67 | # Upstream backend server 68 | upstream backend { 69 | server 127.0.0.1:7965; 70 | keepalive 32; 71 | } 72 | 73 | # Main server block 74 | server { 75 | listen 3000; 76 | server_name localhost; 77 | root /app/frontend/dist; 78 | index index.html; 79 | 80 | # Security headers 81 | add_header X-Frame-Options "SAMEORIGIN" always; 82 | add_header X-Content-Type-Options "nosniff" always; 83 | add_header X-XSS-Protection "1; mode=block" always; 84 | add_header Referrer-Policy "strict-origin-when-cross-origin" always; 85 | add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self';" always; 86 | 87 | # Frontend static files 88 | location / { 89 | try_files $uri $uri/ /index.html; 90 | 91 | # Cache static assets 92 | location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { 93 | expires 1y; 94 | add_header Cache-Control "public, immutable"; 95 | access_log off; 96 | } 97 | 98 | # Cache HTML files 99 | location ~* \.html$ { 100 | expires 1h; 101 | add_header Cache-Control "public, must-revalidate"; 102 | } 103 | } 104 | 105 | # API proxy to backend 106 | location /api/ { 107 | limit_req zone=api_limit burst=20 nodelay; 108 | 109 | proxy_pass http://backend; 110 | proxy_http_version 1.1; 111 | proxy_set_header Upgrade $http_upgrade; 112 | proxy_set_header Connection 'upgrade'; 113 | proxy_set_header Host $host; 114 | proxy_set_header X-Real-IP $remote_addr; 115 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 116 | proxy_set_header X-Forwarded-Proto $scheme; 117 | proxy_cache_bypass $http_upgrade; 118 | 119 | # Timeouts 120 | proxy_connect_timeout 5s; 121 | proxy_send_timeout 30s; 122 | proxy_read_timeout 30s; 123 | 124 | # Buffer settings 125 | proxy_buffering on; 126 | proxy_buffer_size 4k; 127 | proxy_buffers 8 4k; 128 | proxy_busy_buffers_size 8k; 129 | } 130 | 131 | # Health check endpoint 132 | location /health { 133 | access_log off; 134 | return 200 "healthy\n"; 135 | add_header Content-Type text/plain; 136 | } 137 | 138 | # Deny access to sensitive files 139 | location ~ /\. { 140 | deny all; 141 | access_log off; 142 | log_not_found off; 143 | } 144 | 145 | location ~ ~$ { 146 | deny all; 147 | access_log off; 148 | log_not_found off; 149 | } 150 | } 151 | } -------------------------------------------------------------------------------- /backend/src/database/models/AutoSyncConfig.js: -------------------------------------------------------------------------------- 1 | // 自动同步配置模型 2 | const db = require('../connection'); 3 | 4 | class AutoSyncConfig { 5 | // 获取自动同步配置 6 | static get() { 7 | return new Promise((resolve, reject) => { 8 | db.get( 9 | 'SELECT * FROM auto_sync_config ORDER BY id DESC LIMIT 1', 10 | (err, row) => { 11 | if (err) { 12 | return reject(err); 13 | } 14 | // 如果没有配置,返回默认配置 15 | if (!row) { 16 | resolve({ 17 | enabled: 0, 18 | frequency_seconds: 10, 19 | next_sync_time: null, 20 | last_sync_time: null 21 | }); 22 | } else { 23 | resolve(row); 24 | } 25 | } 26 | ); 27 | }); 28 | } 29 | 30 | // 保存或更新自动同步配置 31 | static async save(config) { 32 | const { enabled, frequency_seconds } = config; 33 | return new Promise((resolve, reject) => { 34 | // 先删除旧配置 35 | db.run('DELETE FROM auto_sync_config', (err) => { 36 | if (err) { 37 | return reject(err); 38 | } 39 | 40 | // 插入新配置 41 | db.run( 42 | 'INSERT INTO auto_sync_config (enabled, frequency_seconds, created_at, updated_at) VALUES (?, ?, datetime(CURRENT_TIMESTAMP, "localtime"), datetime(CURRENT_TIMESTAMP, "localtime"))', 43 | [enabled ? 1 : 0, frequency_seconds], 44 | function(err) { 45 | if (err) { 46 | return reject(err); 47 | } 48 | resolve({ 49 | id: this.lastID, 50 | enabled: enabled ? 1 : 0, 51 | frequency_seconds 52 | }); 53 | } 54 | ); 55 | }); 56 | }); 57 | } 58 | 59 | // 更新下次同步时间 60 | static updateNextSyncTime(id, nextSyncTime) { 61 | return new Promise((resolve, reject) => { 62 | db.run( 63 | 'UPDATE auto_sync_config SET next_sync_time = ?, updated_at = datetime(CURRENT_TIMESTAMP, "localtime") WHERE id = ?', 64 | [nextSyncTime, id], 65 | function(err) { 66 | if (err) { 67 | return reject(err); 68 | } 69 | resolve({ changes: this.changes }); 70 | } 71 | ); 72 | }); 73 | } 74 | 75 | // 更新最后同步时间 76 | static updateLastSyncTime(id) { 77 | return new Promise((resolve, reject) => { 78 | db.run( 79 | 'UPDATE auto_sync_config SET last_sync_time = datetime(CURRENT_TIMESTAMP, "localtime"), next_sync_time = NULL, updated_at = datetime(CURRENT_TIMESTAMP, "localtime") WHERE id = ?', 80 | [id], 81 | function(err) { 82 | if (err) { 83 | return reject(err); 84 | } 85 | resolve({ changes: this.changes }); 86 | } 87 | ); 88 | }); 89 | } 90 | 91 | // 计算下次同步时间(使用本地时区) 92 | static calculateNextSyncTime(frequencySeconds) { 93 | const now = new Date(); 94 | // 添加0.5秒缓冲,减少因微小延迟导致的误差 95 | now.setMilliseconds(now.getMilliseconds() + 500); 96 | now.setSeconds(now.getSeconds() + frequencySeconds); 97 | 98 | // 格式化为本地时间字符串(YYYY-MM-DD HH:mm:ss) 99 | const year = now.getFullYear(); 100 | const month = String(now.getMonth() + 1).padStart(2, '0'); 101 | const day = String(now.getDate()).padStart(2, '0'); 102 | const hours = String(now.getHours()).padStart(2, '0'); 103 | const minutes = String(now.getMinutes()).padStart(2, '0'); 104 | const seconds = String(now.getSeconds()).padStart(2, '0'); 105 | 106 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 107 | } 108 | 109 | // 获取需要同步的配置(启用的且时间到了的) 110 | static getDueConfigs() { 111 | return new Promise((resolve, reject) => { 112 | // 使用本地时间(与 calculateNextSyncTime 保持一致) 113 | const now = new Date(); 114 | const year = now.getFullYear(); 115 | const month = String(now.getMonth() + 1).padStart(2, '0'); 116 | const day = String(now.getDate()).padStart(2, '0'); 117 | const hours = String(now.getHours()).padStart(2, '0'); 118 | const minutes = String(now.getMinutes()).padStart(2, '0'); 119 | const seconds = String(now.getSeconds()).padStart(2, '0'); 120 | const nowStr = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; 121 | 122 | db.all( 123 | 'SELECT * FROM auto_sync_config WHERE enabled = 1 AND next_sync_time IS NOT NULL AND next_sync_time <= ?', 124 | [nowStr], 125 | (err, rows) => { 126 | if (err) { 127 | return reject(err); 128 | } 129 | resolve(rows); 130 | } 131 | ); 132 | }); 133 | } 134 | } 135 | 136 | module.exports = AutoSyncConfig; 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 智谱AI GLM Coding Plan 账单统计系统 2 | 3 | 一个专门用于智谱AI GLM Coding Plan套餐的账单管理和统计分析系统,帮助用户实时监控API使用量、Token消耗和费用支出。 4 | 5 | ## 系统特色 6 | 7 | - **专注智谱AI**: 深度适配智谱AI GLM Coding Plan套餐 8 | - **实时监控**: 近5小时/1天/1周/1月的API调用和Token消耗统计 9 | - **智能同步**: 支持全量和增量同步,自动避免重复数据 10 | - **数据本地化**: 使用SQLite3本地数据库,确保数据隐私安全 11 | - **一键启动**: 提供便捷的启动脚本和Docker部署方案 12 | - **丰富图表**: 多种图表类型展示,数据可视化直观 13 | 14 | ## 技术架构 15 | 16 | ### 后端技术栈 17 | - **Node.js 16+** + **Express 4**: 高性能API服务器 18 | - **SQLite3**: 轻量级本地数据库,零配置部署 19 | - **Axios**: 调用智谱AI API的HTTP客户端 20 | 21 | ### 前端技术栈 22 | - **Vue 3**: 现代化前端框架 23 | - **Element Plus**: 企业级UI组件库 24 | - **ECharts + Chart.js**: 专业数据可视化图表 25 | - **Vite**: 快速构建工具 26 | 27 | ### 核心功能 28 | - **数据同步模块**: 全量/增量同步账单数据,支持异步处理和进度监控 29 | - **统计分析模块**: 多维度统计API使用量、Token消耗和费用支出 30 | - **自动监控模块**: 定时同步数据,实时预警套餐使用情况 31 | - **用户界面模块**: 响应式设计,支持多屏幕尺寸 32 | 33 | ## 快速开始 34 | 35 | ### 环境要求 36 | - Node.js >= 16 37 | - npm >= 8 38 | 39 | ### 方式一:一键启动 40 | 41 | ```bash 42 | # 克隆项目 43 | git clone git@github.com:pxvp2008/areYouOk.git 44 | cd areYouOk 45 | 46 | # 一键启动(自动安装依赖、初始化数据库、启动前后端服务) 47 | ./start.sh 48 | ``` 49 | 50 | 启动完成后: 51 | - 前端地址: http://localhost:3000 52 | - 后端地址: http://localhost:7965 53 | 54 | ### 方式二:手动启动 55 | 56 | ```bash 57 | # 1. 启动后端 58 | cd backend 59 | npm install 60 | npm run init-db # 初始化数据库 61 | npm start # 启动后端服务(端口:7965) 62 | 63 | # 2. 启动前端(新开终端) 64 | cd frontend 65 | npm install 66 | npm run dev # 启动前端服务(端口:3000) 67 | ``` 68 | 69 | ### 方式三:Docker部署 70 | 71 | ```bash 72 | # 1. 使用Docker Compose部署(推荐) 73 | docker-compose up -d 74 | 75 | # 2. 使用docker run直接部署 76 | docker run -d \ 77 | --name areyouok-app \ 78 | --restart unless-stopped \ 79 | -p 3000:3000 \ 80 | -v $(pwd)/data:/app/data:rw \ 81 | -v $(pwd)/logs:/app/logs:rw \ 82 | -e NODE_ENV=production \ 83 | -e TZ=Asia/Shanghai \ 84 | pxvp2008/areyouok-app:latest 85 | ``` 86 | 87 | ### 系统功能 88 | ![1](https://github.com/user-attachments/assets/f92e6b35-6471-4a0a-86c3-57ca725211f9) 89 | ![2](https://github.com/user-attachments/assets/df41db0b-357a-4bee-8006-76c9881d7b92) 90 | ![3](https://github.com/user-attachments/assets/6b6932d0-907f-4bed-bf38-3f335193788b) 91 | ![4](https://github.com/user-attachments/assets/68dd537b-f906-40cc-ae70-d86f717a11a6) 92 | ![5](https://github.com/user-attachments/assets/62ccf1c7-b977-4d8f-a561-54957da52fb1) 93 | ![6](https://github.com/user-attachments/assets/b2cc2803-44ab-4fcc-8e23-e8bf4574ce77) 94 | ![7](https://github.com/user-attachments/assets/e500a20d-5250-435c-ac1b-7592aaed2481) 95 | ![8](https://github.com/user-attachments/assets/b0b32e5b-d5f5-4e33-90ef-06e2a0f5e3b3) 96 | ![9](https://github.com/user-attachments/assets/b5ad6eef-ecd2-498c-98d3-85d8cddfb636) 97 | ![10](https://github.com/user-attachments/assets/8a41e509-bbe1-4365-b2db-3bfd5a4cc2b1) 98 | 99 | ## 使用指南 100 | 101 | ### 1. 初始配置 102 | 1. 访问 http://localhost:3000 103 | 2. 首次使用会自动跳转到设置页面 104 | 3. 输入您的智谱AI API Token进行配置 105 | 106 | ### 2. 数据同步 107 | 1. 进入"数据同步"页面 108 | 2. 选择要同步的账单月份 109 | 3. 点击"开始同步",系统会自动: 110 | - 调用智谱AI API获取账单数据 111 | - 智能处理timeWindow时间窗口 112 | - 从billingNo解析交易时间戳 113 | - 避免重复数据入库 114 | 115 | ### 3. 查看统计 116 | 系统提供多维度数据统计: 117 | - **API使用统计**: 近5小时/1天/1周/1月的调用次数和增长率 118 | - **Token消耗统计**: 输入/输出Token使用量和进度条显示 119 | - **费用支出统计**: 累计花费金额和环比分析 120 | - **产品分布统计**: 不同API产品的使用情况 121 | 122 | ### 4. 自动监控 123 | - 配置自动同步频率(建议10秒) 124 | - 系统会定期更新数据并实时显示统计结果 125 | - 接近套餐限制时自动预警 126 | 127 | ## API接口 128 | 129 | ### 核心接口 130 | ```bash 131 | # 同步账单数据 132 | POST /api/bills/sync 133 | Body: {"billingMonth": "2025-11"} 134 | 135 | # 获取账单列表(分页) 136 | GET /api/bills?page=1&pageSize=20&startDate=2025-11-01&endDate=2025-11-30 137 | 138 | # 获取统计信息 139 | GET /api/bills/stats?period=5h 140 | 141 | # 获取同步状态 142 | GET /api/bills/sync-status 143 | 144 | # 获取API使用进度 145 | GET /api/bills/api-usage-progress 146 | ``` 147 | 148 | ### 配置接口 149 | ```bash 150 | # 保存API Token 151 | POST /api/tokens/save 152 | Body: {"token": "your-api-token"} 153 | 154 | # 配置自动同步 155 | POST /api/auto-sync/config 156 | Body: {"enabled": true, "interval": 10} 157 | ``` 158 | 159 | ## 项目结构 160 | 161 | ``` 162 | areYouOk/ 163 | ├── backend/ # Node.js 后端服务 164 | │ ├── src/ 165 | │ │ ├── controllers/ # 控制器层 166 | │ │ ├── services/ # 业务逻辑层 167 | │ │ ├── models/ # 数据模型 168 | │ │ ├── database/ # 数据库配置 169 | │ │ ├── routes/ # 路由定义 170 | │ │ └── utils/ # 工具函数 171 | │ └── package.json 172 | ├── frontend/ # Vue3 前端应用 173 | │ ├── src/ 174 | │ │ ├── views/ # 页面组件 175 | │ │ ├── components/ # 公共组件 176 | │ │ ├── api/ # API接口 177 | │ │ ├── router/ # 路由配置 178 | │ │ └── utils/ # 工具函数 179 | │ └── package.json 180 | ├── data/ # SQLite数据库文件 181 | ├── logs/ # 日志文件 182 | ├── docker-compose.yml # Docker部署配置 183 | ├── docker-build.sh # Docker构建脚本 184 | ├── start.sh # 一键启动脚本 185 | └── stop.sh # 停止服务脚本 186 | ``` 187 | 188 | ## 数据库设计 189 | 190 | ### 核心数据表 191 | - **expense_bills**: 账单明细表(70+字段,完整映射智谱AI数据) 192 | - **api_tokens**: API Token配置表 193 | - **sync_history**: 同步历史记录表 194 | - **auto_sync_config**: 自动同步配置表 195 | - **membership_tier_limits**: 会员等级限制表 196 | 197 | ### 特色字段 198 | - `transaction_time`: 从billingNo智能解析的时间戳(精确到毫秒) 199 | - `time_window_start/end`: 拆分后的时间窗口字段 200 | - `create_time`: 数据入库时间戳 201 | 202 | ## 常见问题 203 | 204 | ### Q: 数据同步失败怎么办? 205 | A: 检查API Token是否正确,网络连接是否正常,查看日志文件了解详细错误信息。 206 | 207 | ### Q: 如何停止服务? 208 | A: 使用 `./stop.sh` 脚本可以优雅停止所有服务,或使用 `Ctrl+C` 中断启动脚本。 209 | 210 | ## 开源协议 211 | 212 | MIT License 213 | -------------------------------------------------------------------------------- /frontend/src/components/ProductBarEChart.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 173 | 174 | 219 | -------------------------------------------------------------------------------- /frontend/src/composables/useApiCall.js: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue' 2 | 3 | /** 4 | * 通用API调用包装器Composable 5 | * 用于消除重复的API调用模式,遵循DRY原则 6 | * 7 | * @param {Function} apiCall - API调用函数 8 | * @param {Object} options - 配置选项 9 | * @param {*} options.defaultValue - 失败时的默认值 10 | * @param {string} options.errorMessage - 自定义错误消息前缀 11 | * @param {Function} options.dataTransformer - 数据转换函数 12 | * @returns {Object} { data, loading, error, execute } 13 | */ 14 | export function useApiCall(apiCall, options = {}) { 15 | const { 16 | defaultValue = null, 17 | errorMessage = '', 18 | dataTransformer = null 19 | } = options 20 | 21 | const data = ref(null) 22 | const loading = ref(false) 23 | const error = ref(null) 24 | 25 | /** 26 | * 执行API调用 27 | * @param {...any} args - 传递给API调用的参数 28 | * @returns {Promise} API调用结果 29 | */ 30 | const execute = async (...args) => { 31 | loading.value = true 32 | error.value = null 33 | 34 | try { 35 | const result = await apiCall(...args) 36 | 37 | if (result.success && result.data !== undefined) { 38 | // 如果有数据转换函数,应用转换 39 | data.value = dataTransformer ? dataTransformer(result.data) : result.data 40 | } else { 41 | // API调用失败,使用默认值 42 | data.value = defaultValue 43 | console.error(`${errorMessage}API返回失败:`, result.message || '未知错误') 44 | } 45 | 46 | return result 47 | } catch (err) { 48 | // 捕获异常,设置默认值和错误信息 49 | data.value = defaultValue 50 | error.value = err 51 | console.error(`${errorMessage}API调用异常:`, err) 52 | return { success: false, error: err } 53 | } finally { 54 | loading.value = false 55 | } 56 | } 57 | 58 | return { 59 | data, 60 | loading, 61 | error, 62 | execute 63 | } 64 | } 65 | 66 | /** 67 | * 简单的单值API调用包装器 68 | * 用于获取单个数值类型的API响应 69 | * 70 | * @param {Function} apiCall - API调用函数 71 | * @param {Object} options - 配置选项 72 | * @param {*} options.defaultValue - 失败时的默认值,默认为0 73 | * @param {string} options.errorMessage - 错误消息前缀 74 | * @param {string} options.dataKey - 从响应数据中提取的键名,默认为'used' 75 | * @returns {Function} 返回一个异步函数,直接执行API调用 76 | */ 77 | export function createSimpleApiHandler(apiCall, options = {}) { 78 | const { 79 | defaultValue = 0, 80 | errorMessage = '', 81 | dataKey = 'used' 82 | } = options 83 | 84 | return async (...args) => { 85 | try { 86 | const result = await apiCall(...args) 87 | if (result.success && result.data) { 88 | return result.data[dataKey] || defaultValue 89 | } 90 | return defaultValue 91 | } catch (error) { 92 | console.error(`${errorMessage}失败:`, error) 93 | return defaultValue 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * 对象型API调用包装器 100 | * 用于获取包含多个字段的API响应 101 | * 102 | * @param {Function} apiCall - API调用函数 103 | * @param {Object} options - 配置选项 104 | * @param {Object} options.defaultValue - 失败时的默认值对象 105 | * @param {string} options.errorMessage - 错误消息前缀 106 | * @returns {Function} 返回一个异步函数,直接执行API调用 107 | */ 108 | export function createObjectApiHandler(apiCall, options = {}) { 109 | const { 110 | defaultValue = {}, 111 | errorMessage = '' 112 | } = options 113 | 114 | return async (...args) => { 115 | try { 116 | const result = await apiCall(...args) 117 | if (result.success && result.data) { 118 | return result.data 119 | } 120 | return defaultValue 121 | } catch (error) { 122 | console.error(`${errorMessage}失败:`, error) 123 | return defaultValue 124 | } 125 | } 126 | } 127 | 128 | /** 129 | * 产品分布数据API调用包装器 130 | * 专门处理产品分布数据格式转换 131 | * 132 | * @param {Function} apiCall - API调用函数 133 | * @param {string} errorMessage - 错误消息前缀 134 | * @returns {Function} 返回一个异步函数,执行API调用并转换数据格式 135 | */ 136 | export function createProductDistributionHandler(apiCall, errorMessage = '') { 137 | return async (...args) => { 138 | try { 139 | const result = await apiCall(...args) 140 | if (result.success && result.data && result.data.length > 0) { 141 | // 从结果中提取产品名称和使用量 142 | const productNames = result.data.map(item => item.productName) 143 | const productValues = result.data.map(item => item.totalUsage) 144 | 145 | return { 146 | productNames, 147 | productValues 148 | } 149 | } else { 150 | // 如果没有数据,使用空数组 151 | return { 152 | productNames: [], 153 | productValues: [] 154 | } 155 | } 156 | } catch (error) { 157 | console.error(`${errorMessage}失败:`, error) 158 | return { 159 | productNames: [], 160 | productValues: [] 161 | } 162 | } 163 | } 164 | } 165 | 166 | /** 167 | * 时间序列数据API调用包装器 168 | * 用于处理图表时间序列数据 169 | * 170 | * @param {Function} apiCall - API调用函数 171 | * @param {string} errorMessage - 错误消息前缀 172 | * @returns {Function} 返回一个异步函数,执行API调用并转换数据格式 173 | */ 174 | export function createTimeSeriesHandler(apiCall, errorMessage = '') { 175 | return async (...args) => { 176 | try { 177 | const result = await apiCall(...args) 178 | if (result.success && result.data) { 179 | return { 180 | labels: result.data.labels || [], 181 | callCountData: result.data.callCountData || [], 182 | tokenData: result.data.tokenData || [] 183 | } 184 | } else { 185 | return { 186 | labels: [], 187 | callCountData: [], 188 | tokenData: [] 189 | } 190 | } 191 | } catch (error) { 192 | console.error(`${errorMessage}失败:`, error) 193 | return { 194 | labels: [], 195 | callCountData: [], 196 | tokenData: [] 197 | } 198 | } 199 | } 200 | } -------------------------------------------------------------------------------- /docker-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | PROJECT_NAME="areyouok" 6 | IMAGE_NAME="areyouok-app" 7 | CONTAINER_NAME="areyouok-app" 8 | DOCKERFILE="Dockerfile" 9 | BUILD_CONTEXT="." 10 | VERSION=${1:-latest} 11 | 12 | RED='\033[0;31m' 13 | GREEN='\033[0;32m' 14 | YELLOW='\033[1;33m' 15 | BLUE='\033[0;34m' 16 | NC='\033[0m' 17 | 18 | log() { 19 | local level=$1 20 | shift 21 | case $level in 22 | INFO) echo -e "${BLUE}[INFO]${NC} $*" ;; 23 | SUCCESS) echo -e "${GREEN}[SUCCESS]${NC} $*" ;; 24 | WARNING) echo -e "${YELLOW}[WARNING]${NC} $*" ;; 25 | ERROR) echo -e "${RED}[ERROR]${NC} $*" ;; 26 | esac 27 | } 28 | 29 | check_docker() { 30 | docker info >/dev/null 2>&1 || { log ERROR "Docker not running"; exit 1; } 31 | } 32 | 33 | check_requirements() { 34 | [ -f "$DOCKERFILE" ] || { log ERROR "Dockerfile not found"; exit 1; } 35 | [ -d "frontend" ] || { log ERROR "Frontend directory not found"; exit 1; } 36 | [ -d "backend" ] || { log ERROR "Backend directory not found"; exit 1; } 37 | [ -f "frontend/package.json" ] || { log ERROR "Frontend package.json not found"; exit 1; } 38 | [ -f "backend/package.json" ] || { log ERROR "Backend package.json not found"; exit 1; } 39 | } 40 | 41 | create_directories() { 42 | mkdir -p data logs backups 43 | chmod 755 data logs backups 44 | } 45 | 46 | stop_existing_container() { 47 | if docker ps -a --format '{{.Names}}' | grep -q "^$CONTAINER_NAME$"; then 48 | log WARNING "Removing existing container: $CONTAINER_NAME" 49 | docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true 50 | docker rm "$CONTAINER_NAME" >/dev/null 2>&1 || true 51 | fi 52 | } 53 | 54 | build_image() { 55 | local full_image_name="${IMAGE_NAME}:${VERSION}" 56 | log INFO "Building: $full_image_name" 57 | 58 | if docker build \ 59 | --no-cache \ 60 | --tag "$full_image_name" \ 61 | --file "$DOCKERFILE" \ 62 | "$BUILD_CONTEXT"; then 63 | 64 | log SUCCESS "Built: $full_image_name" 65 | [ "$VERSION" != "latest" ] && docker tag "$full_image_name" "${IMAGE_NAME}:latest" 66 | else 67 | log ERROR "Build failed" 68 | exit 1 69 | fi 70 | } 71 | 72 | run_container() { 73 | [ ! -d "data" ] && { log ERROR "Data directory missing"; exit 1; } 74 | 75 | log INFO "Starting container: $CONTAINER_NAME" 76 | local full_image_name="${IMAGE_NAME}:${VERSION}" 77 | 78 | if docker run -d \ 79 | --name "$CONTAINER_NAME" \ 80 | --restart unless-stopped \ 81 | -p 3000:3000 \ 82 | -v "$(pwd)/data:/app/data:rw" \ 83 | -v "$(pwd)/logs:/app/logs:rw" \ 84 | -e NODE_ENV=production \ 85 | -e TZ=Asia/Shanghai \ 86 | --health-interval=30s \ 87 | --health-timeout=10s \ 88 | --health-retries=3 \ 89 | "$full_image_name"; then 90 | 91 | log SUCCESS "Container started" 92 | else 93 | log ERROR "Failed to start container" 94 | exit 1 95 | fi 96 | } 97 | 98 | show_status() { 99 | log INFO "Container status:" 100 | docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" 101 | echo "" 102 | echo "Frontend: http://localhost:3000" 103 | echo "API: http://localhost:3000/api/" 104 | echo "Health: http://localhost:3000/health" 105 | } 106 | 107 | show_logs() { 108 | log INFO "Showing logs (Ctrl+C to exit):" 109 | docker logs -f "$CONTAINER_NAME" 110 | } 111 | 112 | main() { 113 | log INFO "Building AreYouOk version: ${VERSION}" 114 | 115 | check_docker 116 | check_requirements 117 | create_directories 118 | build_image 119 | 120 | log SUCCESS "AreYouOk built successfully! Version: ${VERSION}" 121 | log INFO "Use '$0 start' to start container, '$0 logs' to view logs" 122 | } 123 | 124 | if [[ "$1" =~ ^(build|start|stop|logs|status|restart)$ ]]; then 125 | COMMAND="$1" 126 | VERSION="latest" 127 | elif [[ $# -eq 0 ]]; then 128 | COMMAND="build" 129 | VERSION="latest" 130 | else 131 | VERSION="$1" 132 | COMMAND="${2:-build}" 133 | fi 134 | 135 | case "$COMMAND" in 136 | "build") 137 | main 138 | ;; 139 | "start") 140 | log INFO "Starting container..." 141 | run_container 142 | show_status 143 | log SUCCESS "Container started successfully" 144 | ;; 145 | "stop") 146 | log INFO "Stopping container..." 147 | stop_existing_container 148 | log SUCCESS "Container stopped" 149 | ;; 150 | "logs") 151 | show_logs 152 | ;; 153 | "status") 154 | show_status 155 | ;; 156 | "restart") 157 | log INFO "Restarting container..." 158 | stop_existing_container 159 | run_container 160 | show_status 161 | log SUCCESS "Container restarted successfully" 162 | ;; 163 | *) 164 | echo "Usage: $0 [VERSION] {build|start|stop|logs|status|restart}" 165 | echo " VERSION - Docker image version (default: latest)" 166 | echo " build - Build image only (default)" 167 | echo " start - Start container from built image" 168 | echo " stop - Stop and remove container" 169 | echo " logs - Show container logs" 170 | echo " status - Show container status" 171 | echo " restart - Restart container" 172 | echo "" 173 | echo "Examples:" 174 | echo " $0 # Build with version 'latest'" 175 | echo " $0 1.0.0 # Build with version '1.0.0'" 176 | echo " $0 v2.1 build # Build with version 'v2.1'" 177 | echo " $0 latest start # Start container with version 'latest'" 178 | echo " $0 latest stop # Stop container (any version)" 179 | exit 1 180 | ;; 181 | esac -------------------------------------------------------------------------------- /backend/src/controllers/autoSyncController.js: -------------------------------------------------------------------------------- 1 | // 自动同步配置控制器 2 | const AutoSyncConfig = require('../database/models/AutoSyncConfig'); 3 | const syncService = require('../services/syncService'); 4 | 5 | class AutoSyncController { 6 | /** 7 | * 获取自动同步配置 8 | * GET /api/auto-sync/config 9 | */ 10 | async getConfig(req, res) { 11 | try { 12 | const config = await AutoSyncConfig.get(); 13 | 14 | res.json({ 15 | success: true, 16 | data: { 17 | ...config, 18 | enabled: !!config.enabled, 19 | // 计算下次同步倒计时(秒) 20 | countdown: config.next_sync_time 21 | ? Math.max(0, Math.floor((new Date(config.next_sync_time) - new Date()) / 1000)) 22 | : null 23 | } 24 | }); 25 | } catch (error) { 26 | console.error('获取自动同步配置失败:', error); 27 | res.status(500).json({ 28 | success: false, 29 | message: '获取配置失败: ' + error.message 30 | }); 31 | } 32 | } 33 | 34 | /** 35 | * 保存自动同步配置 36 | * POST /api/auto-sync/config 37 | * body: { enabled, frequency_seconds } 38 | */ 39 | async saveConfig(req, res) { 40 | try { 41 | const { enabled, frequency_seconds } = req.body; 42 | 43 | // 验证参数 44 | if (frequency_seconds && ![5, 10, 60, 300].includes(frequency_seconds)) { 45 | return res.status(400).json({ 46 | success: false, 47 | message: '频率只能是: 5s, 10s, 1min, 5min' 48 | }); 49 | } 50 | 51 | // 获取当前配置 52 | const currentConfig = await AutoSyncConfig.get(); 53 | 54 | // 计算下次同步时间 55 | let next_sync_time = null; 56 | if (enabled) { 57 | const frequency = frequency_seconds || currentConfig.frequency_seconds || 10; 58 | next_sync_time = AutoSyncConfig.calculateNextSyncTime(frequency); 59 | } 60 | 61 | // 保存配置 62 | const savedConfig = await AutoSyncConfig.save({ 63 | enabled: !!enabled, 64 | frequency_seconds: frequency_seconds || 10 65 | }); 66 | 67 | // 如果启用了自动同步,更新下次同步时间 68 | if (enabled) { 69 | await AutoSyncConfig.updateNextSyncTime(savedConfig.id, next_sync_time); 70 | } 71 | 72 | res.json({ 73 | success: true, 74 | message: '配置保存成功', 75 | data: { 76 | ...savedConfig, 77 | enabled: !!savedConfig.enabled, 78 | next_sync_time: enabled ? next_sync_time : null 79 | } 80 | }); 81 | 82 | } catch (error) { 83 | console.error('保存自动同步配置失败:', error); 84 | res.status(500).json({ 85 | success: false, 86 | message: '保存配置失败: ' + error.message 87 | }); 88 | } 89 | } 90 | 91 | /** 92 | * 立即触发一次自动同步 93 | * POST /api/auto-sync/trigger 94 | * 自动同步当前月份的账单数据 95 | */ 96 | async triggerSync(req, res) { 97 | try { 98 | const config = await AutoSyncConfig.get(); 99 | 100 | if (!config.enabled) { 101 | return res.status(400).json({ 102 | success: false, 103 | message: '自动同步未启用' 104 | }); 105 | } 106 | 107 | // 获取当前月份(YYYY-MM格式) 108 | const now = new Date(); 109 | const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; 110 | 111 | // 触发增量同步(同步当前月份) 112 | const result = await syncService.incrementalSync(currentMonth); 113 | 114 | // 更新最后同步时间 115 | if (config.id) { 116 | await AutoSyncConfig.updateLastSyncTime(config.id); 117 | } 118 | 119 | // 计算下次同步时间 120 | const nextSyncTime = AutoSyncConfig.calculateNextSyncTime(config.frequency_seconds); 121 | if (config.id) { 122 | await AutoSyncConfig.updateNextSyncTime(config.id, nextSyncTime); 123 | } 124 | 125 | res.json({ 126 | success: true, 127 | message: '自动同步触发成功', 128 | data: { 129 | ...result, 130 | billing_month: currentMonth, 131 | next_sync_time: nextSyncTime 132 | } 133 | }); 134 | 135 | } catch (error) { 136 | console.error('触发自动同步失败:', error); 137 | res.status(500).json({ 138 | success: false, 139 | message: '触发同步失败: ' + error.message 140 | }); 141 | } 142 | } 143 | 144 | /** 145 | * 停止自动同步 146 | * POST /api/auto-sync/stop 147 | */ 148 | async stopSync(req, res) { 149 | try { 150 | const config = await AutoSyncConfig.get(); 151 | 152 | if (config.id) { 153 | // 禁用自动同步 154 | await AutoSyncConfig.save({ 155 | enabled: false, 156 | frequency_seconds: config.frequency_seconds 157 | }); 158 | } 159 | 160 | res.json({ 161 | success: true, 162 | message: '自动同步已停止', 163 | data: { 164 | enabled: false 165 | } 166 | }); 167 | 168 | } catch (error) { 169 | console.error('停止自动同步失败:', error); 170 | res.status(500).json({ 171 | success: false, 172 | message: '停止同步失败: ' + error.message 173 | }); 174 | } 175 | } 176 | } 177 | 178 | module.exports = new AutoSyncController(); 179 | -------------------------------------------------------------------------------- /backend/src/database/models/SyncHistory.js: -------------------------------------------------------------------------------- 1 | // 同步历史日志数据模型 2 | const db = require('../connection'); 3 | 4 | class SyncHistory { 5 | /** 6 | * 创建同步历史记录 7 | * @param {Object} historyData - 同步历史数据 8 | * @returns {Promise} 9 | */ 10 | static create(historyData) { 11 | return new Promise((resolve, reject) => { 12 | const { 13 | sync_type, 14 | billing_month, 15 | status, 16 | synced_count = 0, 17 | failed_count = 0, 18 | total_count = 0, 19 | message = '', 20 | duration = 0 21 | } = historyData; 22 | 23 | const sql = ` 24 | INSERT INTO sync_history ( 25 | sync_type, billing_month, status, 26 | synced_count, failed_count, total_count, 27 | message, duration 28 | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) 29 | `; 30 | 31 | const params = [ 32 | sync_type, billing_month, status, 33 | synced_count, failed_count, total_count, 34 | message, duration 35 | ]; 36 | 37 | db.run(sql, params, function(err) { 38 | if (err) { 39 | console.error('创建同步历史记录失败:', err.message); 40 | reject(err); 41 | } else { 42 | resolve({ 43 | id: this.lastID, 44 | sync_type, 45 | billing_month, 46 | status, 47 | synced_count, 48 | failed_count, 49 | total_count, 50 | message, 51 | duration 52 | }); 53 | } 54 | }); 55 | }); 56 | } 57 | 58 | /** 59 | * 获取指定类型的同步历史记录 60 | * @param {string} syncType - 同步类型 ('incremental' 或 'full') 61 | * @param {number} limit - 限制返回记录数 62 | * @returns {Promise} 63 | */ 64 | static findByType(syncType, limit = 10) { 65 | return new Promise((resolve, reject) => { 66 | const sql = ` 67 | SELECT * 68 | FROM sync_history 69 | WHERE sync_type = ? 70 | ORDER BY sync_time DESC 71 | LIMIT ? 72 | `; 73 | 74 | db.all(sql, [syncType, limit], (err, rows) => { 75 | if (err) { 76 | console.error('获取同步历史记录失败:', err.message); 77 | reject(err); 78 | } else { 79 | resolve(rows); 80 | } 81 | }); 82 | }); 83 | } 84 | 85 | /** 86 | * 获取指定类型的同步历史记录(分页版本) 87 | * @param {string} syncType - 同步类型 ('incremental' 或 'full') 88 | * @param {number} limit - 限制返回记录数 89 | * @param {number} offset - 偏移量 90 | * @returns {Promise<{data: Array, total: number}>} 91 | */ 92 | static findByTypePaginated(syncType, limit = 10, offset = 0) { 93 | return new Promise((resolve, reject) => { 94 | // 先获取总条数 95 | const countSql = ` 96 | SELECT COUNT(*) as total 97 | FROM sync_history 98 | WHERE sync_type = ? 99 | `; 100 | 101 | db.get(countSql, [syncType], (err, countResult) => { 102 | if (err) { 103 | console.error('获取总条数失败:', err.message); 104 | reject(err); 105 | return; 106 | } 107 | 108 | // 再获取分页数据 109 | const dataSql = ` 110 | SELECT * 111 | FROM sync_history 112 | WHERE sync_type = ? 113 | ORDER BY sync_time DESC 114 | LIMIT ? OFFSET ? 115 | `; 116 | 117 | db.all(dataSql, [syncType, limit, offset], (err, rows) => { 118 | if (err) { 119 | console.error('获取同步历史记录失败:', err.message); 120 | reject(err); 121 | } else { 122 | resolve({ 123 | data: rows, 124 | total: countResult.total 125 | }); 126 | } 127 | }); 128 | }); 129 | }); 130 | } 131 | 132 | /** 133 | * 获取所有同步历史记录 134 | * @param {number} limit - 限制返回记录数 135 | * @returns {Promise} 136 | */ 137 | static findAll(limit = 50) { 138 | return new Promise((resolve, reject) => { 139 | const sql = ` 140 | SELECT * 141 | FROM sync_history 142 | ORDER BY sync_time DESC 143 | LIMIT ? 144 | `; 145 | 146 | db.all(sql, [limit], (err, rows) => { 147 | if (err) { 148 | console.error('获取同步历史记录失败:', err.message); 149 | reject(err); 150 | } else { 151 | resolve(rows); 152 | } 153 | }); 154 | }); 155 | } 156 | 157 | /** 158 | * 清理旧记录 159 | * @param {number} days - 保留天数 160 | * @returns {Promise<{count: number}>} 161 | */ 162 | static cleanOldRecords(days = 30) { 163 | return new Promise((resolve, reject) => { 164 | const sql = ` 165 | DELETE FROM sync_history 166 | WHERE sync_time < datetime('now', '-' || ? || ' days') 167 | `; 168 | 169 | db.run(sql, [days], function(err) { 170 | if (err) { 171 | console.error('清理旧记录失败:', err.message); 172 | reject(err); 173 | } else { 174 | resolve({ count: this.changes }); 175 | } 176 | }); 177 | }); 178 | } 179 | } 180 | 181 | module.exports = SyncHistory; 182 | -------------------------------------------------------------------------------- /frontend/src/components/ProductPieChart.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 182 | 183 | 228 | -------------------------------------------------------------------------------- /frontend/src/components/HourlyChart.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 221 | 222 | 261 | -------------------------------------------------------------------------------- /backend/src/database/schema.sql: -------------------------------------------------------------------------------- 1 | -- 数据库模式定义 2 | -- 账单表结构 3 | 4 | -- 账单明细表 5 | CREATE TABLE IF NOT EXISTS expense_bills ( 6 | -- 基础信息 7 | id TEXT PRIMARY KEY, -- 主键ID(UUID,无连字符) 8 | billing_no TEXT, -- 账单编号(唯一但非主键) 9 | billing_date TEXT, -- 账单日期(YYYY-MM-DD) 10 | billing_time TEXT, -- 账单时间 11 | order_no TEXT, -- 订单编号 12 | customer_id TEXT, -- 客户ID 13 | 14 | -- 模型信息 15 | api_key TEXT, -- API密钥 16 | model_code TEXT, -- 模型代码 17 | model_product_type TEXT, -- 模型产品类型 18 | model_product_subtype TEXT, -- 模型产品子类型 19 | model_product_code TEXT, -- 模型产品代码 20 | model_product_name TEXT, -- 模型产品名称 21 | 22 | -- 支付信息 23 | payment_type TEXT, -- 支付类型 24 | start_time TEXT, -- 开始时间 25 | end_time TEXT, -- 结束时间 26 | business_id INTEGER, -- 业务ID 27 | 28 | -- 成本信息 29 | cost_price REAL, -- 成本价格(单位:元/千token) 30 | cost_unit TEXT, -- 成本单位 31 | usage_count INTEGER, -- 使用量 32 | usage_exempt REAL, -- 免费用量 33 | usage_unit TEXT, -- 用量单位 34 | currency TEXT, -- 货币类型 35 | 36 | -- 金额信息 37 | settlement_amount REAL, -- 结算金额 38 | gift_deduct_amount REAL, -- 赠款抵扣金额 39 | due_amount REAL, -- 应付金额 40 | paid_amount REAL, -- 已付金额 41 | unpaid_amount REAL, -- 未付金额 42 | 43 | -- 状态信息 44 | billing_status TEXT, -- 账单状态 45 | 46 | -- 开票信息 47 | invoicing_amount REAL, -- 开票金额 48 | invoiced_amount REAL, -- 已开票金额 49 | 50 | -- Token信息 51 | token_account_id INTEGER, -- Token账户ID 52 | token_resource_no TEXT, -- Token资源编号 53 | token_resource_name TEXT, -- Token资源名称 54 | deduct_usage INTEGER, -- 抵扣用量 55 | deduct_after TEXT, -- 抵扣后信息 56 | 57 | -- 时间窗口(需拆分) 58 | time_window TEXT, -- 时间窗口(原始字段) 59 | time_window_start TEXT, -- 时间窗口开始时间 60 | time_window_end TEXT, -- 时间窗口结束时间 61 | 62 | -- 其他信息 63 | original_amount REAL, -- 原始金额 64 | original_cost_price REAL, -- 原始成本价格 65 | api_usage INTEGER, -- API使用次数 66 | discount_rate REAL, -- 折扣率 67 | discount_type TEXT, -- 折扣类型 68 | credit_pay_amount REAL, -- 信用支付金额 69 | token_type TEXT, -- Token类型(输入/输出) 70 | cash_amount REAL, -- 现金金额 71 | third_party REAL, -- 第三方金额 72 | 73 | -- 元数据 74 | transaction_time DATETIME, -- 交易时间(从billingNo提取) 75 | create_time DATETIME DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')) -- 创建时间 76 | ); 77 | 78 | -- API Token配置表 79 | CREATE TABLE IF NOT EXISTS api_tokens ( 80 | id INTEGER PRIMARY KEY AUTOINCREMENT, 81 | token TEXT NOT NULL, 82 | provider TEXT DEFAULT 'zhipu', 83 | created_at DATETIME DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')), 84 | updated_at DATETIME DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')) 85 | ); 86 | 87 | -- 创建索引 88 | CREATE INDEX IF NOT EXISTS idx_billing_date ON expense_bills(billing_date); 89 | CREATE INDEX IF NOT EXISTS idx_customer_id ON expense_bills(customer_id); 90 | CREATE INDEX IF NOT EXISTS idx_model_code ON expense_bills(model_code); 91 | CREATE INDEX IF NOT EXISTS idx_time_window_start ON expense_bills(time_window_start); 92 | CREATE INDEX IF NOT EXISTS idx_create_time ON expense_bills(create_time); 93 | 94 | -- 同步历史日志表 95 | CREATE TABLE IF NOT EXISTS sync_history ( 96 | id INTEGER PRIMARY KEY AUTOINCREMENT, 97 | sync_type TEXT NOT NULL CHECK(sync_type IN ('incremental', 'full')), 98 | billing_month TEXT NOT NULL, 99 | sync_time DATETIME NOT NULL DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')), 100 | status TEXT NOT NULL, 101 | synced_count INTEGER DEFAULT 0, 102 | failed_count INTEGER DEFAULT 0, 103 | total_count INTEGER DEFAULT 0, 104 | message TEXT, 105 | duration INTEGER 106 | ); 107 | 108 | -- 创建同步历史索引 109 | CREATE INDEX IF NOT EXISTS idx_sync_history_type ON sync_history(sync_type); 110 | CREATE INDEX IF NOT EXISTS idx_sync_history_time ON sync_history(sync_time); 111 | CREATE INDEX IF NOT EXISTS idx_sync_history_month ON sync_history(billing_month); 112 | 113 | -- 自动同步配置表 114 | CREATE TABLE IF NOT EXISTS auto_sync_config ( 115 | id INTEGER PRIMARY KEY AUTOINCREMENT, 116 | enabled INTEGER NOT NULL DEFAULT 0, -- 是否启用自动同步 (0/1) 117 | frequency_seconds INTEGER NOT NULL DEFAULT 10, -- 同步频率(秒) 118 | next_sync_time DATETIME, -- 下次同步时间 119 | last_sync_time DATETIME, -- 最后一次同步时间 120 | created_at DATETIME NOT NULL DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')), 121 | updated_at DATETIME NOT NULL DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')) 122 | ); 123 | 124 | -- 创建自动同步配置索引 125 | CREATE INDEX IF NOT EXISTS idx_auto_sync_enabled ON auto_sync_config(enabled); 126 | CREATE INDEX IF NOT EXISTS idx_auto_sync_next_time ON auto_sync_config(next_sync_time); 127 | 128 | -- 会员等级调用次数限制表 129 | CREATE TABLE IF NOT EXISTS membership_tier_limits ( 130 | id INTEGER PRIMARY KEY AUTOINCREMENT, 131 | tier_name TEXT NOT NULL UNIQUE, -- 会员等级名称(如:GLM Coding Lite) 132 | period_hours INTEGER NOT NULL DEFAULT 5, -- 时间周期(小时) 133 | call_limit INTEGER NOT NULL, -- 调用次数限制 134 | created_at DATETIME NOT NULL DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')), 135 | updated_at DATETIME NOT NULL DEFAULT(datetime(CURRENT_TIMESTAMP, 'localtime')) 136 | ); 137 | 138 | -- 创建索引 139 | CREATE INDEX IF NOT EXISTS idx_tier_name ON membership_tier_limits(tier_name); 140 | 141 | -- 初始化会员等级限制数据 142 | INSERT OR IGNORE INTO membership_tier_limits (tier_name, period_hours, call_limit) VALUES 143 | ('GLM Coding Lite', 5, 2400), 144 | ('GLM Coding Pro', 5, 12000), 145 | ('GLM Coding Max', 5, 48000); 146 | -------------------------------------------------------------------------------- /backend/src/services/apiService.js: -------------------------------------------------------------------------------- 1 | // API调用服务 2 | // 封装智谱AI接口调用逻辑 3 | const axios = require('axios'); 4 | const Token = require('../database/models/Token'); 5 | class ApiService { 6 | constructor() { 7 | this.apiUrl = process.env.BIGMODEL_API_URL || 'https://bigmodel.cn/api/finance/expenseBill/expenseBillList'; 8 | this.maxRetries = 3; 9 | this.retryDelay = 1000; // 1秒 10 | // 公共分页配置 11 | this.defaultPageSize = 100; // 默认每页大小 12 | this.minDelay = 500; // API调用最小延迟(毫秒) 13 | this.maxDelay = 2000; // API调用最大延迟(毫秒) 14 | } 15 | /** 16 | * 从数据库获取API Token 17 | * @returns {Promise} Token字符串 18 | * @throws {Error} 如果未找到Token 19 | */ 20 | async getTokenFromDB() { 21 | const tokenRecord = await Token.get(); 22 | if (!tokenRecord || !tokenRecord.token) { 23 | throw new Error('未在数据库中找到API Token,请先在设置页面配置Token'); 24 | } 25 | return tokenRecord.token; 26 | } 27 | 28 | /** 29 | * 调用智谱AI账单查询接口 30 | * @param {string} billingMonth - 账单月份 (YYYY-MM) 31 | * @param {number} pageNum - 页码 (从1开始) 32 | * @param {number} pageSize - 每页记录数 33 | * @param {number} retryCount - 重试次数 34 | * @returns {Promise} API响应数据 35 | */ 36 | async fetchBills(billingMonth, pageNum = 1, pageSize = 20, retryCount = 0) { 37 | // 动态从数据库获取最新Token 38 | const token = await this.getTokenFromDB(); 39 | 40 | const params = { 41 | billingMonth, 42 | pageNum, 43 | pageSize 44 | }; 45 | const headers = { 46 | 'Authorization': `Bearer ${token}`, 47 | 'Content-Type': 'application/json' 48 | }; 49 | try { 50 | const response = await axios.get(this.apiUrl, { 51 | params, 52 | headers, 53 | timeout: 10000 // 10秒超时 54 | }); 55 | if (response.data.code !== 200) { 56 | throw new Error(`API返回错误: ${response.data.msg || '未知错误'}`); 57 | } 58 | return response.data; 59 | } catch (error) { 60 | console.error(`API调用失败 (尝试 ${retryCount + 1}/${this.maxRetries}):`, error.message); 61 | // 如果是网络错误且还有重试次数,则重试 62 | if (retryCount < this.maxRetries && this.isRetryableError(error)) { 63 | await this.delay(this.retryDelay); 64 | return this.fetchBills(billingMonth, pageNum, pageSize, retryCount + 1); 65 | } 66 | // 重试次数用完或不可重试的错误 67 | throw new Error(`API调用失败: ${error.message}`); 68 | } 69 | } 70 | /** 71 | * 获取账单数据(自动分页) 72 | * @param {string} billingMonth - 账单月份 (YYYY-MM) 73 | * @param {number} pageSize - 每页记录数,默认为defaultPageSize 74 | * @param {Function} onProgress - 进度回调函数,接收 (current, total) 参数 75 | * @returns {Promise} 所有账单数据 76 | */ 77 | async fetchAllBills(billingMonth, pageSize = null, onProgress = null) { 78 | // 如果未指定pageSize,使用默认配置 79 | if (pageSize === null) { 80 | pageSize = this.defaultPageSize; 81 | } 82 | const allBills = []; 83 | let pageNum = 1; 84 | let hasMore = true; 85 | let totalPages = null; // 先设为null,在第一次获取后确定 86 | const apiBillNoSet = new Set(); 87 | const duplicateApiBillNos = []; 88 | while (hasMore) { 89 | try { 90 | const data = await this.fetchBills(billingMonth, pageNum, pageSize); 91 | if (!data.rows || data.rows.length === 0) { 92 | hasMore = false; 93 | // 如果有进度回调,报告完成 94 | if (onProgress && totalPages) { 95 | onProgress(allBills.length, allBills.length); 96 | } 97 | } else { 98 | allBills.push(...data.rows); 99 | // 在第一次获取后确定总页数 100 | if (totalPages === null) { 101 | totalPages = Math.ceil(data.total / pageSize); 102 | } 103 | // 检查当前页数据中是否有重复的billingNo 104 | for (const bill of data.rows) { 105 | if (bill.billingNo) { 106 | if (apiBillNoSet.has(bill.billingNo)) { 107 | duplicateApiBillNos.push(bill.billingNo); 108 | } 109 | apiBillNoSet.add(bill.billingNo); 110 | } 111 | } 112 | // 如果有进度回调,报告当前进度 113 | if (onProgress) { 114 | onProgress(allBills.length, data.total); 115 | } 116 | // 检查是否还有更多数据 117 | if (pageNum >= totalPages) { 118 | hasMore = false; 119 | } else { 120 | pageNum++; 121 | } 122 | } 123 | // 在下次请求前添加随机延迟,防止被封 124 | if (hasMore) { 125 | const delay = this.getRandomDelay(); 126 | await this.delay(delay); 127 | } 128 | } catch (error) { 129 | console.error(`获取第${pageNum}页数据失败:`, error.message); 130 | throw error; 131 | } 132 | } 133 | if (duplicateApiBillNos.length > 0) { 134 | } else { 135 | } 136 | return allBills; 137 | } 138 | /** 139 | * 检查错误是否可重试 140 | * @param {Error} error - 错误对象 141 | * @returns {boolean} 是否可重试 142 | */ 143 | isRetryableError(error) { 144 | // 网络错误、超时错误、5xx服务器错误可重试 145 | if (error.code === 'ECONNRESET' || 146 | error.code === 'ETIMEDOUT' || 147 | error.code === 'ECONNREFUSED' || 148 | error.code === 'ENOTFOUND' || 149 | (error.response && error.response.status >= 500)) { 150 | return true; 151 | } 152 | return false; 153 | } 154 | /** 155 | * 延迟函数 156 | * @param {number} ms - 延迟毫秒数 157 | * @returns {Promise} 158 | */ 159 | delay(ms) { 160 | return new Promise(resolve => setTimeout(resolve, ms)); 161 | } 162 | /** 163 | * 生成随机延迟(minDelay-maxDelay毫秒) 164 | * @returns {number} 延迟毫秒数 165 | */ 166 | getRandomDelay() { 167 | const range = this.maxDelay - this.minDelay; 168 | return Math.floor(Math.random() * range) + this.minDelay; 169 | } 170 | } 171 | module.exports = new ApiService(); 172 | -------------------------------------------------------------------------------- /backend/src/utils/dataTransform.js: -------------------------------------------------------------------------------- 1 | // 数据转换工具函数 2 | // 处理API返回数据的格式转换 3 | const dayjs = require('dayjs'); 4 | const utc = require('dayjs/plugin/utc'); 5 | const customParseFormat = require('dayjs/plugin/customParseFormat'); 6 | // 加载插件 7 | dayjs.extend(utc); 8 | dayjs.extend(customParseFormat); 9 | /** 10 | * 从billingNo中解析transaction_time 11 | * @param {Object} apiData - API返回的账单数据 12 | * @returns {string|null} 解析出的transaction_time字符串,格式:YYYY-MM-DD HH:mm:ss.SSS 13 | */ 14 | function extractTransactionTime(apiData) { 15 | if (apiData.billingNo && apiData.customerId) { 16 | try { 17 | const customerId = apiData.customerId; 18 | const billingNo = apiData.billingNo; 19 | // 在billingNo中查找customerId的位置 20 | const index = billingNo.indexOf(customerId); 21 | if (index !== -1) { 22 | // 截取customerId后面的所有字符 23 | const afterCustomer = billingNo.substring(index + customerId.length); 24 | // 从剩余字符串中提取前13位(时间戳) 25 | const timestampStr = afterCustomer.substring(0, 13); 26 | if (timestampStr.length === 13) { 27 | const timestamp = parseInt(timestampStr, 10); 28 | if (!isNaN(timestamp)) { 29 | // 使用dayjs处理时区转换 30 | // 1. 将时间戳作为UTC时间解析 31 | // 2. 添加8小时转换为北京时间 32 | // 3. 格式化为 YYYY-MM-DD HH:mm:ss.SSS 33 | const transactionTime = dayjs.utc(timestamp).local().format('YYYY-MM-DD HH:mm:ss.SSS'); 34 | return transactionTime; 35 | } else { 36 | return null; 37 | } 38 | } else { 39 | return null; 40 | } 41 | } else { 42 | return null; 43 | } 44 | } catch (error) { 45 | console.error('提取transaction_time失败:', error.message); 46 | return null; 47 | } 48 | } 49 | return null; 50 | } 51 | /** 52 | * 转换API账单数据为数据库格式 53 | * @param {Object} apiData - API返回的账单数据 54 | * @returns {Object} 转换后的数据 55 | */ 56 | function transformBillData(apiData) { 57 | // 字段映射:将API字段名转换为数据库字段名(camelCase -> snake_case) 58 | const fieldMap = { 59 | billingNo: 'billing_no', 60 | billingDate: 'billing_date', 61 | billingTime: 'billing_time', 62 | orderNo: 'order_no', 63 | customerId: 'customer_id', 64 | apiKey: 'api_key', 65 | modelCode: 'model_code', 66 | modelProductType: 'model_product_type', 67 | modelProductSubtype: 'model_product_subtype', 68 | modelProductCode: 'model_product_code', 69 | modelProductName: 'model_product_name', 70 | paymentType: 'payment_type', 71 | startTime: 'start_time', 72 | endTime: 'end_time', 73 | businessId: 'business_id', 74 | costPrice: 'cost_price', 75 | costUnit: 'cost_unit', 76 | usageCount: 'usage_count', 77 | usageExempt: 'usage_exempt', 78 | usageUnit: 'usage_unit', 79 | currency: 'currency', 80 | settlementAmount: 'settlement_amount', 81 | giftDeductAmount: 'gift_deduct_amount', 82 | dueAmount: 'due_amount', 83 | paidAmount: 'paid_amount', 84 | unpaidAmount: 'unpaid_amount', 85 | billingStatus: 'billing_status', 86 | invoicingAmount: 'invoicing_amount', 87 | invoicedAmount: 'invoiced_amount', 88 | tokenAccountId: 'token_account_id', 89 | tokenResourceNo: 'token_resource_no', 90 | tokenResourceName: 'token_resource_name', 91 | deductUsage: 'deduct_usage', 92 | deductAfter: 'deduct_after', 93 | timeWindow: 'time_window', 94 | originalAmount: 'original_amount', 95 | originalCostPrice: 'original_cost_price', 96 | apiUsage: 'api_usage', 97 | discountRate: 'discount_rate', 98 | discountType: 'discount_type', 99 | creditPayAmount: 'credit_pay_amount', 100 | tokenType: 'token_type', 101 | cashAmount: 'cash_amount', 102 | thirdParty: 'third_party' 103 | }; 104 | // 创建转换后的数据对象 105 | const transformedData = {}; 106 | // 复制所有字段 107 | for (const [apiKey, dbKey] of Object.entries(fieldMap)) { 108 | if (apiData[apiKey] !== undefined) { 109 | transformedData[dbKey] = apiData[apiKey]; 110 | } 111 | } 112 | // 特殊处理1:拆分timeWindow 113 | if (apiData.timeWindow) { 114 | const timeWindow = apiData.timeWindow; 115 | const timeParts = timeWindow.split('~'); 116 | if (timeParts.length === 2) { 117 | transformedData.time_window_start = timeParts[0].trim(); 118 | transformedData.time_window_end = timeParts[1].trim(); 119 | } else { 120 | } 121 | } 122 | // 特殊处理2:从billingNo提取transaction_time 123 | const transactionTime = extractTransactionTime(apiData); 124 | if (transactionTime) { 125 | transformedData.transaction_time = transactionTime; 126 | } 127 | return transformedData; 128 | } 129 | /** 130 | * 验证转换后的数据 131 | * @param {Object} data - 转换后的数据 132 | * @returns {Object} 验证结果 133 | */ 134 | function validateTransformedData(data) { 135 | const errors = []; 136 | // 必填字段检查 137 | if (!data.billing_no) { 138 | errors.push('缺少billing_no字段'); 139 | } 140 | if (!data.customer_id) { 141 | errors.push('缺少customer_id字段'); 142 | } 143 | // 数据类型检查 144 | if (data.cost_price && typeof data.cost_price !== 'number') { 145 | errors.push('cost_price必须是数字类型'); 146 | } 147 | if (data.usage_count && typeof data.usage_count !== 'number') { 148 | errors.push('usage_count必须是数字类型'); 149 | } 150 | if (data.transaction_time && !(data.transaction_time instanceof Date)) { 151 | errors.push('transaction_time必须是Date类型'); 152 | } 153 | return { 154 | valid: errors.length === 0, 155 | errors 156 | }; 157 | } 158 | /** 159 | * 批量转换数据 160 | * @param {Array} apiDataList - API数据列表 161 | * @returns {Array} 转换后的数据列表 162 | */ 163 | function transformBillDataList(apiDataList) { 164 | return apiDataList.map((apiData, index) => { 165 | try { 166 | const transformed = transformBillData(apiData); 167 | const validation = validateTransformedData(transformed); 168 | if (!validation.valid) { 169 | } 170 | return transformed; 171 | } catch (error) { 172 | console.error(`第${index + 1}条数据转换失败:`, error.message); 173 | return null; 174 | } 175 | }).filter(item => item !== null); 176 | } 177 | module.exports = { 178 | transformBillData, 179 | validateTransformedData, 180 | transformBillDataList, 181 | extractTransactionTime 182 | }; 183 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 73 | 74 | 81 | 82 | 289 | -------------------------------------------------------------------------------- /frontend/src/components/HourlyEChart.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 284 | 285 | 330 | -------------------------------------------------------------------------------- /frontend/src/components/ProductPieEChart.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 314 | 315 | 389 | -------------------------------------------------------------------------------- /frontend/src/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 82 | 83 | 185 | 186 | 405 | -------------------------------------------------------------------------------- /frontend/src/views/Onboarding.vue: -------------------------------------------------------------------------------- 1 | 123 | 124 | 232 | 233 | 414 | -------------------------------------------------------------------------------- /backend/src/controllers/billController.js: -------------------------------------------------------------------------------- 1 | // 账单控制器 2 | // 处理HTTP请求和响应 3 | const syncService = require('../services/syncService'); 4 | const Bill = require('../database/models/Bill'); 5 | const SyncHistory = require('../database/models/SyncHistory'); 6 | const MembershipTierLimit = require('../database/models/MembershipTierLimit'); 7 | 8 | class BillController { 9 | /** 10 | * 同步账单数据 11 | * POST /api/bills/sync 12 | * body: { billingMonth: '2025-11', type: 'full' | 'incremental' } 13 | */ 14 | async syncBills(req, res) { 15 | try { 16 | const { billingMonth, type = 'full' } = req.body; 17 | 18 | // 验证参数 19 | if (!billingMonth) { 20 | return res.status(400).json({ 21 | success: false, 22 | message: '缺少必要参数: billingMonth' 23 | }); 24 | } 25 | 26 | // 验证日期格式 27 | const monthRegex = /^\d{4}-\d{2}$/; 28 | if (!monthRegex.test(billingMonth)) { 29 | return res.status(400).json({ 30 | success: false, 31 | message: 'billingMonth格式不正确,应为YYYY-MM格式' 32 | }); 33 | } 34 | 35 | // 检查是否正在同步 36 | if (syncService.isSyncing()) { 37 | return res.status(409).json({ 38 | success: false, 39 | message: '数据同步正在进行中,请稍后再试' 40 | }); 41 | } 42 | 43 | // 对于全量同步,使用新的异步启动方法 44 | if (type === 'full') { 45 | const result = await syncService.startSync(billingMonth); 46 | res.json(result); 47 | } else { 48 | // 增量同步仍然使用同步方式 49 | const result = await syncService.incrementalSync(billingMonth); 50 | res.json({ 51 | success: true, 52 | message: '增量数据同步完成', 53 | data: result 54 | }); 55 | } 56 | 57 | } catch (error) { 58 | console.error('同步账单数据失败:', error); 59 | res.status(500).json({ 60 | success: false, 61 | message: error.message || '同步失败' 62 | }); 63 | } 64 | } 65 | 66 | /** 67 | * 获取账单列表 68 | * GET /api/bills 69 | * query: { page=1, pageSize=20, startDate, endDate, productName } 70 | */ 71 | async getBills(req, res) { 72 | try { 73 | const { 74 | page = 1, 75 | pageSize = 20, 76 | startDate, 77 | endDate, 78 | productName, 79 | productNames 80 | } = req.query; 81 | 82 | // 单用户系统,无需认证 83 | const result = await Bill.findAll({ 84 | page: parseInt(page), 85 | pageSize: parseInt(pageSize), 86 | startDate, 87 | endDate, 88 | productName, 89 | productNames 90 | }); 91 | 92 | res.json({ 93 | success: true, 94 | message: '获取成功', 95 | data: result 96 | }); 97 | 98 | } catch (error) { 99 | console.error('获取账单列表失败:', error); 100 | res.status(500).json({ 101 | success: false, 102 | message: error.message || '获取失败' 103 | }); 104 | } 105 | } 106 | 107 | /** 108 | * 验证Token是否有效 109 | * @param {string} token - 要验证的token 110 | * @returns {Promise} 验证结果 111 | */ 112 | static async verifyToken(token) { 113 | try { 114 | // 使用提供的token调用智谱AI API 115 | const axios = require('axios'); 116 | const authHeader = `Bearer ${token}` 117 | 118 | const response = await axios.get('https://bigmodel.cn/api/finance/expenseBill/expenseBillList', { 119 | params: { 120 | billingMonth: '2025-11', 121 | pageNum: 1, 122 | pageSize: 1 123 | }, 124 | headers: { 125 | 'Authorization': authHeader, 126 | 'Content-Type': 'application/json' 127 | }, 128 | timeout: 10000 129 | }); 130 | 131 | // 检查API返回状态 132 | if (response.data && response.data.code === 200) { 133 | return true; 134 | } else { 135 | return false; 136 | } 137 | } catch (error) { 138 | return false; 139 | } 140 | } 141 | 142 | /** 143 | * 获取账单统计信息 144 | * GET /api/bills/stats 145 | * query: { period: '5h' | '1d' | '1m' } 146 | */ 147 | async getStats(req, res) { 148 | try { 149 | const { period = '5h' } = req.query; 150 | 151 | const stats = await Bill.getStatistics(period); 152 | 153 | res.json({ 154 | success: true, 155 | message: '获取成功', 156 | data: stats 157 | }); 158 | 159 | } catch (error) { 160 | console.error('获取统计信息失败:', error); 161 | res.status(500).json({ 162 | success: false, 163 | message: error.message || '获取失败' 164 | }); 165 | } 166 | } 167 | 168 | /** 169 | * 获取同步状态 170 | * GET /api/bills/sync-status 171 | */ 172 | async getSyncStatus(req, res) { 173 | try { 174 | const isSyncing = syncService.isSyncing(); 175 | const progress = syncService.getProgress(); 176 | 177 | res.json({ 178 | success: true, 179 | data: { 180 | syncing: isSyncing, 181 | ...progress 182 | } 183 | }); 184 | 185 | } catch (error) { 186 | console.error('获取同步状态失败:', error); 187 | res.status(500).json({ 188 | success: false, 189 | message: error.message || '获取失败' 190 | }); 191 | } 192 | } 193 | 194 | /** 195 | * 获取产品列表 196 | * GET /api/bills/products 197 | */ 198 | async getProducts(req, res) { 199 | try { 200 | // 单用户系统,无需认证 201 | const products = await Bill.getProductList(); 202 | 203 | res.json({ 204 | success: true, 205 | message: '获取成功', 206 | data: products 207 | }); 208 | 209 | } catch (error) { 210 | console.error('获取产品列表失败:', error); 211 | res.status(500).json({ 212 | success: false, 213 | message: error.message || '获取失败' 214 | }); 215 | } 216 | } 217 | 218 | /** 219 | * 保存同步历史记录 220 | * POST /api/bills/sync-history 221 | * body: { sync_type, billing_month, status, synced_count, failed_count, total_count, message, duration } 222 | */ 223 | async saveSyncHistory(req, res) { 224 | try { 225 | const { 226 | sync_type, 227 | billing_month, 228 | status, 229 | synced_count = 0, 230 | failed_count = 0, 231 | total_count = 0, 232 | message = '', 233 | duration = 0 234 | } = req.body; 235 | 236 | // 验证参数 237 | if (!sync_type || !billing_month || !status) { 238 | return res.status(400).json({ 239 | success: false, 240 | message: '缺少必要参数: sync_type, billing_month, status' 241 | }); 242 | } 243 | 244 | // 验证同步类型 245 | if (!['incremental', 'full'].includes(sync_type)) { 246 | return res.status(400).json({ 247 | success: false, 248 | message: 'sync_type 必须是 incremental 或 full' 249 | }); 250 | } 251 | 252 | const history = await SyncHistory.create({ 253 | sync_type, 254 | billing_month, 255 | status, 256 | synced_count, 257 | failed_count, 258 | total_count, 259 | message, 260 | duration 261 | }); 262 | 263 | res.json({ 264 | success: true, 265 | message: '保存成功', 266 | data: history 267 | }); 268 | 269 | } catch (error) { 270 | console.error('保存同步历史记录失败:', error); 271 | res.status(500).json({ 272 | success: false, 273 | message: error.message || '保存失败' 274 | }); 275 | } 276 | } 277 | 278 | /** 279 | * 获取同步历史记录 280 | * GET /api/bills/sync-history 281 | * query: { sync_type: 'incremental' | 'full', limit: 10, offset: 0 } 282 | */ 283 | async getSyncHistory(req, res) { 284 | try { 285 | const { sync_type, limit = 10, offset = 0 } = req.query; 286 | 287 | let result; 288 | if (sync_type && ['incremental', 'full'].includes(sync_type)) { 289 | result = await SyncHistory.findByTypePaginated(sync_type, parseInt(limit), parseInt(offset)); 290 | // result 格式: { data: [...], total: number } 291 | res.json({ 292 | success: true, 293 | message: '获取成功', 294 | data: result.data, 295 | total: result.total 296 | }); 297 | } else { 298 | result = await SyncHistory.findAll(parseInt(limit)); 299 | // 非分页查询不提供总条数 300 | res.json({ 301 | success: true, 302 | message: '获取成功', 303 | data: result 304 | }); 305 | } 306 | 307 | } catch (error) { 308 | console.error('获取同步历史记录失败:', error); 309 | res.status(500).json({ 310 | success: false, 311 | message: error.message || '获取失败' 312 | }); 313 | } 314 | } 315 | 316 | /** 317 | * 获取expense_bills表的记录总数 318 | * GET /api/bills/count 319 | * 用于自动同步功能校验:检查是否有基础数据 320 | */ 321 | async getTotalCount(req, res) { 322 | try { 323 | const count = await Bill.getTotalCount(); 324 | 325 | res.json({ 326 | success: true, 327 | message: '获取成功', 328 | data: { 329 | total: count, 330 | hasData: count > 0 331 | } 332 | }); 333 | 334 | } catch (error) { 335 | console.error('获取记录总数失败:', error); 336 | res.status(500).json({ 337 | success: false, 338 | message: error.message || '获取失败' 339 | }); 340 | } 341 | } 342 | 343 | /** 344 | * 获取当前会员等级 345 | * GET /api/bills/current-membership-tier 346 | * 从数据库expense_bills表中,根据transaction_time字段倒序取top1的token_resource_name 347 | */ 348 | async getCurrentMembershipTier(req, res) { 349 | try { 350 | const membershipTier = await Bill.getCurrentMembershipTier(); 351 | 352 | res.json({ 353 | success: true, 354 | message: '获取成功', 355 | data: { 356 | membershipTier 357 | } 358 | }); 359 | 360 | } catch (error) { 361 | console.error('获取当前会员等级失败:', error); 362 | res.status(500).json({ 363 | success: false, 364 | message: error.message || '获取失败' 365 | }); 366 | } 367 | } 368 | 369 | /** 370 | * 获取API使用进度 371 | * GET /api/bills/api-usage-progress 372 | */ 373 | async getApiUsageProgress(req, res) { 374 | try { 375 | // 1. 获取当前会员等级(token_resource_name格式) 376 | const membershipTier = await Bill.getCurrentMembershipTier(); 377 | 378 | // 2. 使用智能匹配方法获取该会员等级的调用次数限制 379 | // 能正确处理"GLM Coding Lite - 包季计划"与"GLM Coding Lite"的匹配 380 | const tierLimit = await MembershipTierLimit.getLimitByTokenResourceName(membershipTier); 381 | 382 | // 3. 获取近5小时的实际使用量 383 | const recentUsage = await Bill.getRecentApiUsage(5); 384 | 385 | // 4. 计算小时级环比增长率(当前1小时 vs 前1小时) 386 | const growthRate = await Bill.getHourlyGrowthRate(); 387 | 388 | // 5. 计算进度 389 | const used = recentUsage; 390 | const limit = tierLimit.call_limit; 391 | const percentage = Math.min(Math.round((used / limit) * 100), 100); 392 | const remaining = Math.max(limit - used, 0); 393 | 394 | res.json({ 395 | success: true, 396 | message: '获取成功', 397 | data: { 398 | membershipTier, 399 | used, 400 | limit, 401 | percentage, 402 | remaining, 403 | periodHours: tierLimit.period_hours, 404 | growthRate, 405 | // 统计维度说明 406 | dimensions: { 407 | usedLabel: '近5小时调用次数', 408 | usedPeriod: '过去5小时', 409 | growthRateLabel: '小时级环比增长率', 410 | growthRatePeriod: '当前1小时 vs 前1小时' 411 | } 412 | } 413 | }); 414 | 415 | } catch (error) { 416 | console.error('获取API使用进度失败:', error); 417 | res.status(500).json({ 418 | success: false, 419 | message: error.message || '获取失败' 420 | }); 421 | } 422 | } 423 | 424 | /** 425 | * 获取Token使用量统计 426 | * GET /api/bills/token-usage-progress 427 | */ 428 | async getTokenUsageProgress(req, res) { 429 | try { 430 | // 1. 获取近5小时的Token使用总量(deduct_usage字段求和) 431 | const recentUsage = await Bill.getRecentDeductUsage(5); 432 | 433 | // 2. 计算Token使用量的小时级环比增长率 434 | const growthRate = await Bill.getDeductUsageHourlyGrowthRate(); 435 | 436 | res.json({ 437 | success: true, 438 | message: '获取成功', 439 | data: { 440 | used: recentUsage, 441 | growthRate, 442 | // 统计维度说明 443 | dimensions: { 444 | usedLabel: '近5小时Token使用量', 445 | usedPeriod: '过去5小时', 446 | growthRateLabel: '小时级环比增长率', 447 | growthRatePeriod: '当前1小时 vs 前1小时' 448 | } 449 | } 450 | }); 451 | 452 | } catch (error) { 453 | console.error('获取Token使用量统计失败:', error); 454 | res.status(500).json({ 455 | success: false, 456 | message: error.message || '获取失败' 457 | }); 458 | } 459 | } 460 | 461 | /** 462 | * 获取累计花费金额统计 463 | * GET /api/bills/total-cost-progress 464 | */ 465 | async getTotalCostProgress(req, res) { 466 | try { 467 | // 1. 获取近5小时的总花费金额 468 | // 计算公式:SUM(cost_price/1000 * deduct_usage) 469 | const recentCost = await Bill.getRecentTotalCost(5); 470 | 471 | // 2. 计算总花费金额的小时级环比增长率 472 | const growthRate = await Bill.getTotalCostHourlyGrowthRate(); 473 | 474 | res.json({ 475 | success: true, 476 | message: '获取成功', 477 | data: { 478 | used: recentCost, 479 | growthRate, 480 | // 统计维度说明 481 | dimensions: { 482 | usedLabel: '近5小时累计花费金额', 483 | usedPeriod: '过去5小时', 484 | growthRateLabel: '小时级环比增长率', 485 | growthRatePeriod: '当前1小时 vs 前1小时' 486 | } 487 | } 488 | }); 489 | 490 | } catch (error) { 491 | console.error('获取累计花费金额统计失败:', error); 492 | res.status(500).json({ 493 | success: false, 494 | message: error.message || '获取失败' 495 | }); 496 | } 497 | } 498 | 499 | /** 500 | * 获取每小时调用次数和Token数量 501 | * GET /api/bills/hourly-usage 502 | * query: { hours = 5 } 503 | */ 504 | async getHourlyUsage(req, res) { 505 | try { 506 | const { hours = 5 } = req.query; 507 | 508 | const hourlyData = await Bill.getHourlyUsage(parseInt(hours)); 509 | 510 | res.json({ 511 | success: true, 512 | message: '获取成功', 513 | data: hourlyData 514 | }); 515 | 516 | } catch (error) { 517 | console.error('获取每小时调用次数和Token数量失败:', error); 518 | res.status(500).json({ 519 | success: false, 520 | message: error.message || '获取失败' 521 | }); 522 | } 523 | } 524 | 525 | /** 526 | * 获取每天调用次数和Token数量 527 | * GET /api/bills/daily-usage 528 | * query: { days = 7 } 529 | */ 530 | async getDailyUsage(req, res) { 531 | try { 532 | const { days = 7 } = req.query; 533 | 534 | const dailyData = await Bill.getDailyUsage(parseInt(days)); 535 | 536 | res.json({ 537 | success: true, 538 | message: '获取成功', 539 | data: dailyData 540 | }); 541 | 542 | } catch (error) { 543 | console.error('获取每天调用次数和Token数量失败:', error); 544 | res.status(500).json({ 545 | success: false, 546 | message: error.message || '获取失败' 547 | }); 548 | } 549 | } 550 | 551 | /** 552 | * 获取产品分布统计(按model_product_name分组,统计sum(api_usage)) 553 | * GET /api/bills/product-distribution 554 | * query: { hours = 5 } 555 | */ 556 | async getProductDistribution(req, res) { 557 | try { 558 | const { hours = 5 } = req.query; 559 | 560 | const productData = await Bill.getProductDistributionByHours(parseInt(hours)); 561 | 562 | res.json({ 563 | success: true, 564 | message: '获取成功', 565 | data: productData 566 | }); 567 | 568 | } catch (error) { 569 | console.error('获取产品分布统计失败:', error); 570 | res.status(500).json({ 571 | success: false, 572 | message: error.message || '获取失败' 573 | }); 574 | } 575 | } 576 | 577 | /** 578 | * 获取近1天API使用量统计 579 | * GET /api/bills/day-api-usage 580 | */ 581 | async getDayApiUsage(req, res) { 582 | try { 583 | const usage = await Bill.getRecentApiUsage(24); 584 | 585 | res.json({ 586 | success: true, 587 | message: '获取成功', 588 | data: { 589 | used: usage 590 | } 591 | }); 592 | 593 | } catch (error) { 594 | console.error('获取近1天API使用量失败:', error); 595 | res.status(500).json({ 596 | success: false, 597 | message: error.message || '获取失败' 598 | }); 599 | } 600 | } 601 | 602 | /** 603 | * 获取近1天Token使用量统计 604 | * GET /api/bills/day-token-usage 605 | */ 606 | async getDayTokenUsage(req, res) { 607 | try { 608 | const usage = await Bill.getRecentDeductUsage(24); 609 | 610 | res.json({ 611 | success: true, 612 | message: '获取成功', 613 | data: { 614 | used: usage 615 | } 616 | }); 617 | 618 | } catch (error) { 619 | console.error('获取近1天Token使用量失败:', error); 620 | res.status(500).json({ 621 | success: false, 622 | message: error.message || '获取失败' 623 | }); 624 | } 625 | } 626 | 627 | /** 628 | * 获取近1天累计花费金额统计 629 | * GET /api/bills/day-total-cost 630 | */ 631 | async getDayTotalCost(req, res) { 632 | try { 633 | const cost = await Bill.getRecentTotalCost(24); 634 | 635 | res.json({ 636 | success: true, 637 | message: '获取成功', 638 | data: { 639 | used: cost 640 | } 641 | }); 642 | 643 | } catch (error) { 644 | console.error('获取近1天累计花费金额失败:', error); 645 | res.status(500).json({ 646 | success: false, 647 | message: error.message || '获取失败' 648 | }); 649 | } 650 | } 651 | 652 | /** 653 | * 获取近1周API使用量统计 654 | * GET /api/bills/week-api-usage 655 | */ 656 | async getWeekApiUsage(req, res) { 657 | try { 658 | const usage = await Bill.getRecentApiUsage(168); 659 | 660 | res.json({ 661 | success: true, 662 | message: '获取成功', 663 | data: { 664 | used: usage 665 | } 666 | }); 667 | 668 | } catch (error) { 669 | console.error('获取近1周API使用量失败:', error); 670 | res.status(500).json({ 671 | success: false, 672 | message: error.message || '获取失败' 673 | }); 674 | } 675 | } 676 | 677 | /** 678 | * 获取近1周Token使用量统计 679 | * GET /api/bills/week-token-usage 680 | */ 681 | async getWeekTokenUsage(req, res) { 682 | try { 683 | const usage = await Bill.getRecentDeductUsage(168); 684 | 685 | res.json({ 686 | success: true, 687 | message: '获取成功', 688 | data: { 689 | used: usage 690 | } 691 | }); 692 | 693 | } catch (error) { 694 | console.error('获取近1周Token使用量失败:', error); 695 | res.status(500).json({ 696 | success: false, 697 | message: error.message || '获取失败' 698 | }); 699 | } 700 | } 701 | 702 | /** 703 | * 获取近1周累计花费金额统计 704 | * GET /api/bills/week-total-cost 705 | */ 706 | async getWeekTotalCost(req, res) { 707 | try { 708 | const cost = await Bill.getRecentTotalCost(168); 709 | 710 | res.json({ 711 | success: true, 712 | message: '获取成功', 713 | data: { 714 | used: cost 715 | } 716 | }); 717 | 718 | } catch (error) { 719 | console.error('获取近1周累计花费金额失败:', error); 720 | res.status(500).json({ 721 | success: false, 722 | message: error.message || '获取失败' 723 | }); 724 | } 725 | } 726 | 727 | /** 728 | * 获取近1月API使用量统计 729 | * GET /api/bills/month-api-usage 730 | */ 731 | async getMonthApiUsage(req, res) { 732 | try { 733 | const usage = await Bill.getRecentApiUsage(720); 734 | 735 | res.json({ 736 | success: true, 737 | message: '获取成功', 738 | data: { 739 | used: usage 740 | } 741 | }); 742 | 743 | } catch (error) { 744 | console.error('获取近1月API使用量失败:', error); 745 | res.status(500).json({ 746 | success: false, 747 | message: error.message || '获取失败' 748 | }); 749 | } 750 | } 751 | 752 | /** 753 | * 获取近1月Token使用量统计 754 | * GET /api/bills/month-token-usage 755 | */ 756 | async getMonthTokenUsage(req, res) { 757 | try { 758 | const usage = await Bill.getRecentDeductUsage(720); 759 | 760 | res.json({ 761 | success: true, 762 | message: '获取成功', 763 | data: { 764 | used: usage 765 | } 766 | }); 767 | 768 | } catch (error) { 769 | console.error('获取近1月Token使用量失败:', error); 770 | res.status(500).json({ 771 | success: false, 772 | message: error.message || '获取失败' 773 | }); 774 | } 775 | } 776 | 777 | /** 778 | * 获取近1月累计花费金额统计 779 | * GET /api/bills/month-total-cost 780 | */ 781 | async getMonthTotalCost(req, res) { 782 | try { 783 | const cost = await Bill.getRecentTotalCost(720); 784 | 785 | res.json({ 786 | success: true, 787 | message: '获取成功', 788 | data: { 789 | used: cost 790 | } 791 | }); 792 | 793 | } catch (error) { 794 | console.error('获取近1月累计花费金额失败:', error); 795 | res.status(500).json({ 796 | success: false, 797 | message: error.message || '获取失败' 798 | }); 799 | } 800 | } 801 | } 802 | 803 | module.exports = new BillController(); 804 | --------------------------------------------------------------------------------