Remove Deno-specific configuration files and migrate to Cloudflare Workers environment

This commit is contained in:
wood chen 2025-02-08 17:32:58 +08:00
parent 0012d45205
commit 7487aaadda
45 changed files with 4333 additions and 2678 deletions

View File

@ -1,10 +0,0 @@
# Discourse SSO 配置
# Discourse 网站地址
DISCOURSE_URL=https://q58.pro
# SSO 密钥 (必需)
# 可以使用以下命令生成: openssl rand -hex 32
DISCOURSE_SSO_SECRET=your_sso_secret_here
# 服务器配置
PORT=8000

59
.github/workflows/docker-build.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Docker Build and Push
on:
push:
branches: [ "main" ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
IMAGE_NAME: ${{ secrets.DOCKER_HUB_USERNAME }}/aimodels-prices
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
# 设置 QEMU 以支持多架构构建
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# 设置 Docker Buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 登录到 Docker Hub
- name: Log into Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
# 提取版本信息
- name: Extract version
id: version
run: |
if [[ $GITHUB_REF == refs/tags/* ]]; then
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
else
echo "VERSION=latest" >> $GITHUB_OUTPUT
fi
# 构建并推送 Docker 镜像
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
tags: |
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
${{ env.IMAGE_NAME }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ kaifa.md
.env .env
.env.local .env.local
.env.*.local .env.*.local
wrangler.toml
# 系统文件 # 系统文件
.DS_Store .DS_Store

58
Dockerfile Normal file
View File

@ -0,0 +1,58 @@
# 第一阶段:构建后端
FROM golang:1.21-alpine AS backend-builder
WORKDIR /app/backend
# 安装依赖
COPY backend/go.mod backend/go.sum ./
RUN go mod download
# 复制后端源代码
COPY backend/ .
# 编译后端
RUN CGO_ENABLED=0 GOOS=linux go build -o main .
# 第二阶段:构建前端
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
# 安装依赖
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
# 复制前端源代码
COPY frontend/ .
# 构建前端
RUN npm run build
# 第三阶段:最终镜像
FROM alpine:3.18
WORKDIR /app
# 安装 nginx
RUN apk add --no-cache nginx
# 创建数据目录
RUN mkdir -p /app/data
# 复制后端二进制文件
COPY --from=backend-builder /app/backend/main ./
COPY backend/config/nginx.conf /etc/nginx/nginx.conf
# 复制前端构建产物
COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/static
COPY --from=frontend-builder /app/frontend/public /app/frontend/public
COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend
# 复制启动脚本
COPY scripts/start.sh ./
RUN chmod +x start.sh
EXPOSE 80
# 启动服务
CMD ["./start.sh"]

47
backend/config/config.go Normal file
View File

@ -0,0 +1,47 @@
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/joho/godotenv"
)
type Config struct {
DBPath string
ServerPort string
}
func LoadConfig() (*Config, error) {
// 确保数据目录存在
dbDir := "./data"
if err := os.MkdirAll(dbDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %v", err)
}
// 尝试从 data 目录加载 .env 文件
envPath := filepath.Join(dbDir, ".env")
if err := godotenv.Load(envPath); err != nil {
fmt.Printf("Warning: .env file not found in data directory: %v\n", err)
// 如果 data/.env 不存在,尝试加载项目根目录的 .env
if err := godotenv.Load(); err != nil {
fmt.Printf("Warning: .env file not found in root directory: %v\n", err)
}
}
config := &Config{
DBPath: filepath.Join(dbDir, "aimodels.db"),
ServerPort: getEnv("PORT", "8080"),
}
return config, nil
}
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}

67
backend/database/db.go Normal file
View File

@ -0,0 +1,67 @@
package database
import (
"database/sql"
"log"
_ "modernc.org/sqlite"
"aimodels-prices/models"
)
// DB 是数据库连接的全局实例
var DB *sql.DB
// InitDB 初始化数据库连接
func InitDB(dbPath string) error {
var err error
DB, err = sql.Open("sqlite", dbPath)
if err != nil {
return err
}
// 测试连接
if err = DB.Ping(); err != nil {
return err
}
// 设置连接池参数
DB.SetMaxOpenConns(10)
DB.SetMaxIdleConns(5)
// 创建表结构
if err = createTables(); err != nil {
return err
}
return nil
}
// createTables 创建数据库表
func createTables() error {
// 创建用户表
if _, err := DB.Exec(models.CreateUserTableSQL()); err != nil {
log.Printf("Failed to create user table: %v", err)
return err
}
// 创建会话表
if _, err := DB.Exec(models.CreateSessionTableSQL()); err != nil {
log.Printf("Failed to create session table: %v", err)
return err
}
// 创建供应商表
if _, err := DB.Exec(models.CreateProviderTableSQL()); err != nil {
log.Printf("Failed to create provider table: %v", err)
return err
}
// 创建价格表
if _, err := DB.Exec(models.CreatePriceTableSQL()); err != nil {
log.Printf("Failed to create price table: %v", err)
return err
}
return nil
}

51
backend/go.mod Normal file
View File

@ -0,0 +1,51 @@
module aimodels-prices
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/joho/godotenv v1.5.1
modernc.org/sqlite v1.28.0
)
require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/tools v0.6.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.2.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.29.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/token v1.0.1 // indirect
)

134
backend/go.sum Normal file
View File

@ -0,0 +1,134 @@
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.29.0 h1:tTFRFq69YKCF2QyGNuRUQxKBm1uZZLubf6Cjh/pVHXs=
modernc.org/libc v1.29.0/go.mod h1:DaG/4Q3LRRdqpiLyP0C2m1B8ZMGkQ+cCgOIjEtQlYhQ=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.28.0 h1:Zx+LyDDmXczNnEQdvPuEfcFVA2ZPyaD7UCZDjef3BHQ=
modernc.org/sqlite v1.28.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.2 h1:C4ybAYCGJw968e+Me18oW55kD/FexcHbqH2xak1ROSY=
modernc.org/tcl v1.15.2/go.mod h1:3+k/ZaEbKrC8ePv8zJWPtBSW0V7Gg9g8rkmhI1Kfs3c=
modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.3 h1:zDJf6iHjrnB+WRD88stbXokugjyc0/pB91ri1gO6LZY=
modernc.org/z v1.7.3/go.mod h1:Ipv4tsdxZRbQyLq9Q1M6gdbkxYzdlrciF2Hi/lS7nWE=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

291
backend/handlers/auth.go Normal file
View File

@ -0,0 +1,291 @@
package handlers
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"aimodels-prices/models"
)
func generateSessionID() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func GetAuthStatus(c *gin.Context) {
cookie, err := c.Cookie("session")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
return
}
db := c.MustGet("db").(*sql.DB)
var session models.Session
err = db.QueryRow("SELECT id, user_id, expires_at, created_at, updated_at, deleted_at FROM session WHERE id = ?", cookie).Scan(
&session.ID, &session.UserID, &session.ExpiresAt, &session.CreatedAt, &session.UpdatedAt, &session.DeletedAt)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid session"})
return
}
if session.ExpiresAt.Before(time.Now()) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
return
}
user, err := session.GetUser(db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
c.Set("user", user)
c.JSON(http.StatusOK, gin.H{
"user": user,
})
}
func Login(c *gin.Context) {
// 开发环境下使用测试账号
if gin.Mode() != gin.ReleaseMode {
db := c.MustGet("db").(*sql.DB)
// 创建测试用户(如果不存在)
var count int
err := db.QueryRow("SELECT COUNT(*) FROM user WHERE username = 'admin'").Scan(&count)
if err != nil || count == 0 {
_, err = db.Exec("INSERT INTO user (username, email, role) VALUES (?, ?, ?)",
"admin", "admin@test.com", "admin")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create test user"})
return
}
}
// 获取用户ID
var userID uint
err = db.QueryRow("SELECT id FROM user WHERE username = 'admin'").Scan(&userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
// 创建会话
sessionID := generateSessionID()
expiresAt := time.Now().Add(24 * time.Hour)
_, err = db.Exec("INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)",
sessionID, userID, expiresAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
// 设置cookie
c.SetCookie("session", sessionID, int(24*time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged in successfully"})
return
}
// 生产环境使用 Discourse SSO
discourseURL := os.Getenv("DISCOURSE_URL")
if discourseURL == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Discourse URL not configured"})
return
}
// 生成随机 nonce
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate nonce"})
return
}
nonceStr := hex.EncodeToString(nonce)
// 构建 payload
payload := url.Values{}
payload.Set("nonce", nonceStr)
payload.Set("return_sso_url", fmt.Sprintf("%s/api/auth/callback", c.Request.Host))
// Base64 编码
payloadStr := base64.StdEncoding.EncodeToString([]byte(payload.Encode()))
// 计算签名
ssoSecret := os.Getenv("DISCOURSE_SSO_SECRET")
if ssoSecret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "SSO secret not configured"})
return
}
h := hmac.New(sha256.New, []byte(ssoSecret))
h.Write([]byte(payloadStr))
sig := hex.EncodeToString(h.Sum(nil))
// 构建重定向 URL
redirectURL := fmt.Sprintf("%s/session/sso_provider?sso=%s&sig=%s",
discourseURL, url.QueryEscape(payloadStr), sig)
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func Logout(c *gin.Context) {
cookie, err := c.Cookie("session")
if err == nil {
db := c.MustGet("db").(*sql.DB)
db.Exec("DELETE FROM session WHERE id = ?", cookie)
}
c.SetCookie("session", "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
}
func GetUser(c *gin.Context) {
cookie, err := c.Cookie("session")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
return
}
db := c.MustGet("db").(*sql.DB)
var session models.Session
if err := db.QueryRow("SELECT id, user_id, expires_at, created_at, updated_at, deleted_at FROM session WHERE id = ?", cookie).Scan(
&session.ID, &session.UserID, &session.ExpiresAt, &session.CreatedAt, &session.UpdatedAt, &session.DeletedAt); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid session"})
return
}
user, err := session.GetUser(db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
return
}
c.JSON(http.StatusOK, gin.H{
"user": user,
})
}
func AuthCallback(c *gin.Context) {
sso := c.Query("sso")
sig := c.Query("sig")
if sso == "" || sig == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing parameters"})
return
}
// 获取 SSO 密钥
ssoSecret := os.Getenv("DISCOURSE_SSO_SECRET")
if ssoSecret == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "SSO secret not configured"})
return
}
// 验证签名
h := hmac.New(sha256.New, []byte(ssoSecret))
h.Write([]byte(sso))
computedSig := hex.EncodeToString(h.Sum(nil))
if computedSig != sig {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid signature"})
return
}
// 解码 SSO payload
payload, err := base64.StdEncoding.DecodeString(sso)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid SSO payload"})
return
}
// 解析 payload
values, err := url.ParseQuery(string(payload))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload format"})
return
}
// 获取用户信息
username := values.Get("username")
email := values.Get("email")
groups := values.Get("groups")
admin := values.Get("admin") // Discourse 管理员标志
moderator := values.Get("moderator") // Discourse 版主标志
if username == "" || email == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing user information"})
return
}
// 判断用户角色
role := "user"
// 如果是管理员、版主或属于 admins 组,都赋予管理权限
if admin == "true" || moderator == "true" || (groups != "" && strings.Contains(groups, "admins")) {
role = "admin"
}
db := c.MustGet("db").(*sql.DB)
// 检查用户是否存在
var user models.User
err = db.QueryRow("SELECT id, username, email, role FROM user WHERE email = ?", email).Scan(
&user.ID, &user.Username, &user.Email, &user.Role)
if err == sql.ErrNoRows {
// 创建新用户
result, err := db.Exec(`
INSERT INTO user (username, email, role)
VALUES (?, ?, ?)`,
username, email, role)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
return
}
userID, _ := result.LastInsertId()
user = models.User{
ID: uint(userID),
Username: username,
Email: email,
Role: role,
}
} else if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
return
} else {
// 更新现有用户的角色(如果需要)
if user.Role != role {
_, err = db.Exec("UPDATE user SET role = ? WHERE id = ?", role, user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user role"})
return
}
user.Role = role
}
}
// 创建会话
sessionID := generateSessionID()
expiresAt := time.Now().Add(24 * time.Hour)
_, err = db.Exec("INSERT INTO session (id, user_id, expires_at) VALUES (?, ?, ?)",
sessionID, user.ID, expiresAt)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
// 设置 cookie
c.SetCookie("session", sessionID, int(24*time.Hour.Seconds()), "/", "", false, true)
// 重定向到前端
c.Redirect(http.StatusTemporaryRedirect, "/")
}

274
backend/handlers/prices.go Normal file
View File

@ -0,0 +1,274 @@
package handlers
import (
"database/sql"
"net/http"
"time"
"github.com/gin-gonic/gin"
"aimodels-prices/models"
)
func GetPrices(c *gin.Context) {
db := c.MustGet("db").(*sql.DB)
rows, err := db.Query(`
SELECT id, model, billing_type, channel_type, currency, input_price, output_price,
price_source, status, created_at, updated_at, created_by,
temp_model, temp_billing_type, temp_channel_type, temp_currency,
temp_input_price, temp_output_price, temp_price_source, updated_by
FROM price ORDER BY created_at DESC`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch prices"})
return
}
defer rows.Close()
var prices []models.Price
for rows.Next() {
var price models.Price
if err := rows.Scan(
&price.ID, &price.Model, &price.BillingType, &price.ChannelType, &price.Currency,
&price.InputPrice, &price.OutputPrice, &price.PriceSource, &price.Status,
&price.CreatedAt, &price.UpdatedAt, &price.CreatedBy,
&price.TempModel, &price.TempBillingType, &price.TempChannelType, &price.TempCurrency,
&price.TempInputPrice, &price.TempOutputPrice, &price.TempPriceSource, &price.UpdatedBy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan price"})
return
}
prices = append(prices, price)
}
c.JSON(http.StatusOK, prices)
}
func CreatePrice(c *gin.Context) {
var price models.Price
if err := c.ShouldBindJSON(&price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证供应商ID是否存在
db := c.MustGet("db").(*sql.DB)
var providerExists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM provider WHERE id = ?)", price.ChannelType).Scan(&providerExists)
if err != nil || !providerExists {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
now := time.Now()
result, err := db.Exec(`
INSERT INTO price (model, billing_type, channel_type, currency, input_price, output_price,
price_source, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
price.Model, price.BillingType, price.ChannelType, price.Currency,
price.InputPrice, price.OutputPrice, price.PriceSource, price.CreatedBy,
now, now)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create price"})
return
}
id, _ := result.LastInsertId()
price.ID = uint(id)
price.Status = "pending"
price.CreatedAt = now
price.UpdatedAt = now
c.JSON(http.StatusCreated, price)
}
func UpdatePriceStatus(c *gin.Context) {
id := c.Param("id")
var input struct {
Status string `json:"status" binding:"required,oneof=approved rejected"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*sql.DB)
now := time.Now()
if input.Status == "approved" {
// 如果是批准,将临时字段的值更新到正式字段
_, err := db.Exec(`
UPDATE price
SET model = COALESCE(temp_model, model),
billing_type = COALESCE(temp_billing_type, billing_type),
channel_type = COALESCE(temp_channel_type, channel_type),
currency = COALESCE(temp_currency, currency),
input_price = COALESCE(temp_input_price, input_price),
output_price = COALESCE(temp_output_price, output_price),
price_source = COALESCE(temp_price_source, price_source),
status = ?,
updated_at = ?,
temp_model = NULL,
temp_billing_type = NULL,
temp_channel_type = NULL,
temp_currency = NULL,
temp_input_price = NULL,
temp_output_price = NULL,
temp_price_source = NULL,
updated_by = NULL
WHERE id = ?`, input.Status, now, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update price status"})
return
}
} else {
// 如果是拒绝,清除临时字段
_, err := db.Exec(`
UPDATE price
SET status = ?,
updated_at = ?,
temp_model = NULL,
temp_billing_type = NULL,
temp_channel_type = NULL,
temp_currency = NULL,
temp_input_price = NULL,
temp_output_price = NULL,
temp_price_source = NULL,
updated_by = NULL
WHERE id = ?`, input.Status, now, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update price status"})
return
}
}
c.JSON(http.StatusOK, gin.H{
"message": "Status updated successfully",
"status": input.Status,
"updated_at": now,
})
}
// UpdatePrice 更新价格
func UpdatePrice(c *gin.Context) {
id := c.Param("id")
var price models.Price
if err := c.ShouldBindJSON(&price); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证供应商ID是否存在
db := c.MustGet("db").(*sql.DB)
var providerExists bool
err := db.QueryRow("SELECT EXISTS(SELECT 1 FROM provider WHERE id = ?)", price.ChannelType).Scan(&providerExists)
if err != nil || !providerExists {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
return
}
// 获取当前用户
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
return
}
currentUser := user.(*models.User)
now := time.Now()
// 将新的价格信息存储到临时字段
_, err = db.Exec(`
UPDATE price
SET temp_model = ?, temp_billing_type = ?, temp_channel_type = ?, temp_currency = ?,
temp_input_price = ?, temp_output_price = ?, temp_price_source = ?,
updated_by = ?, updated_at = ?, status = 'pending'
WHERE id = ?`,
price.Model, price.BillingType, price.ChannelType, price.Currency,
price.InputPrice, price.OutputPrice, price.PriceSource,
currentUser.Username, now, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update price"})
return
}
// 获取更新后的价格信息
err = db.QueryRow(`
SELECT id, model, billing_type, channel_type, currency, input_price, output_price,
price_source, status, created_at, updated_at, created_by,
temp_model, temp_billing_type, temp_channel_type, temp_currency,
temp_input_price, temp_output_price, temp_price_source, updated_by
FROM price WHERE id = ?`, id).Scan(
&price.ID, &price.Model, &price.BillingType, &price.ChannelType, &price.Currency,
&price.InputPrice, &price.OutputPrice, &price.PriceSource, &price.Status,
&price.CreatedAt, &price.UpdatedAt, &price.CreatedBy,
&price.TempModel, &price.TempBillingType, &price.TempChannelType, &price.TempCurrency,
&price.TempInputPrice, &price.TempOutputPrice, &price.TempPriceSource, &price.UpdatedBy)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updated price"})
return
}
c.JSON(http.StatusOK, price)
}
// DeletePrice 删除价格
func DeletePrice(c *gin.Context) {
id := c.Param("id")
db := c.MustGet("db").(*sql.DB)
_, err := db.Exec("DELETE FROM price WHERE id = ?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete price"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Price deleted successfully"})
}
// PriceRate 价格倍率结构
type PriceRate struct {
Model string `json:"model"`
Type string `json:"type"`
ChannelType uint `json:"channel_type"`
Input float64 `json:"input"`
Output float64 `json:"output"`
}
// GetPriceRates 获取价格倍率
func GetPriceRates(c *gin.Context) {
db := c.MustGet("db").(*sql.DB)
rows, err := db.Query(`
SELECT model, billing_type, channel_type,
CASE
WHEN currency = 'USD' THEN input_price / 2
ELSE input_price / 14
END as input_rate,
CASE
WHEN currency = 'USD' THEN output_price / 2
ELSE output_price / 14
END as output_rate
FROM price
WHERE status = 'approved'
ORDER BY model, channel_type`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch price rates"})
return
}
defer rows.Close()
var rates []PriceRate
for rows.Next() {
var rate PriceRate
if err := rows.Scan(
&rate.Model,
&rate.Type,
&rate.ChannelType,
&rate.Input,
&rate.Output); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan price rate"})
return
}
rates = append(rates, rate)
}
c.JSON(http.StatusOK, rates)
}

View File

@ -0,0 +1,188 @@
package handlers
import (
"database/sql"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"aimodels-prices/models"
)
// GetProviders 获取所有供应商
func GetProviders(c *gin.Context) {
db := c.MustGet("db").(*sql.DB)
rows, err := db.Query(`
SELECT id, name, icon, created_at, updated_at, created_by
FROM provider ORDER BY id`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch providers"})
return
}
defer rows.Close()
var providers []models.Provider
for rows.Next() {
var provider models.Provider
if err := rows.Scan(
&provider.ID, &provider.Name, &provider.Icon,
&provider.CreatedAt, &provider.UpdatedAt, &provider.CreatedBy); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan provider"})
return
}
providers = append(providers, provider)
}
c.JSON(http.StatusOK, providers)
}
// CreateProvider 创建供应商
func CreateProvider(c *gin.Context) {
var provider models.Provider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 检查ID是否已存在
db := c.MustGet("db").(*sql.DB)
var existingID int
err := db.QueryRow("SELECT id FROM provider WHERE id = ?", provider.ID).Scan(&existingID)
if err != sql.ErrNoRows {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID already exists"})
return
}
// 获取当前用户
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"})
return
}
currentUser := user.(*models.User)
now := time.Now()
_, err = db.Exec(`
INSERT INTO provider (id, name, icon, created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
provider.ID, provider.Name, provider.Icon, now, now, currentUser.Username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
return
}
provider.CreatedAt = now
provider.UpdatedAt = now
provider.CreatedBy = currentUser.Username
c.JSON(http.StatusCreated, provider)
}
// UpdateProvider 更新供应商
func UpdateProvider(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"})
return
}
var provider models.Provider
if err := c.ShouldBindJSON(&provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*sql.DB)
now := time.Now()
_, err = db.Exec(`
UPDATE provider
SET name = ?, icon = ?, updated_at = ?
WHERE id = ?`,
provider.Name, provider.Icon, now, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
return
}
// 获取更新后的供应商信息
err = db.QueryRow(`
SELECT id, name, icon, created_at, updated_at, created_by
FROM provider WHERE id = ?`, id).Scan(
&provider.ID, &provider.Name, &provider.Icon,
&provider.CreatedAt, &provider.UpdatedAt, &provider.CreatedBy)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get updated provider"})
return
}
c.JSON(http.StatusOK, provider)
}
// UpdateProviderStatus 更新供应商状态
func UpdateProviderStatus(c *gin.Context) {
id := c.Param("id")
var input struct {
Status string `json:"status" binding:"required,oneof=approved rejected"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
db := c.MustGet("db").(*sql.DB)
now := time.Now()
if input.Status == "approved" {
// 如果是批准,将临时字段的值更新到正式字段
_, err := db.Exec(`
UPDATE provider
SET name = COALESCE(temp_name, name),
icon = COALESCE(temp_icon, icon),
status = ?,
updated_at = ?,
temp_name = NULL,
temp_icon = NULL,
updated_by = NULL
WHERE id = ?`, input.Status, now, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider status"})
return
}
} else {
// 如果是拒绝,清除临时字段
_, err := db.Exec(`
UPDATE provider
SET status = ?,
updated_at = ?,
temp_name = NULL,
temp_icon = NULL,
updated_by = NULL
WHERE id = ?`, input.Status, now, id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider status"})
return
}
}
c.JSON(http.StatusOK, gin.H{
"message": "Status updated successfully",
"status": input.Status,
"updated_at": now,
})
}
// DeleteProvider 删除供应商
func DeleteProvider(c *gin.Context) {
id := c.Param("id")
db := c.MustGet("db").(*sql.DB)
_, err := db.Exec("DELETE FROM provider WHERE id = ?", id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Provider deleted successfully"})
}

98
backend/main.go Normal file
View File

@ -0,0 +1,98 @@
package main
import (
"fmt"
"log"
"github.com/gin-gonic/gin"
"aimodels-prices/config"
"aimodels-prices/database"
"aimodels-prices/handlers"
"aimodels-prices/middleware"
)
func main() {
// 加载配置
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
// 初始化数据库
if err := database.InitDB(cfg.DBPath); err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
defer database.DB.Close()
// 设置gin模式
if gin.Mode() == gin.ReleaseMode {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
// 注入数据库
r.Use(func(c *gin.Context) {
c.Set("db", database.DB)
c.Next()
})
// CORS中间件
r.Use(func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
if origin != "" {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
}
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
// API路由组
api := r.Group("/api")
{
// 价格相关路由
prices := api.Group("/prices")
{
prices.GET("", handlers.GetPrices)
prices.GET("/rates", handlers.GetPriceRates)
prices.POST("", middleware.AuthRequired(), handlers.CreatePrice)
prices.PUT("/:id", middleware.AuthRequired(), handlers.UpdatePrice)
prices.DELETE("/:id", middleware.AuthRequired(), handlers.DeletePrice)
prices.PUT("/:id/status", middleware.AuthRequired(), middleware.AdminRequired(), handlers.UpdatePriceStatus)
}
// 供应商相关路由
providers := api.Group("/providers")
{
providers.GET("", handlers.GetProviders)
providers.POST("", middleware.AuthRequired(), handlers.CreateProvider)
providers.PUT("/:id", middleware.AuthRequired(), middleware.AdminRequired(), handlers.UpdateProvider)
providers.DELETE("/:id", middleware.AuthRequired(), middleware.AdminRequired(), handlers.DeleteProvider)
}
// 认证相关路由
auth := api.Group("/auth")
{
auth.GET("/status", handlers.GetAuthStatus)
auth.POST("/login", handlers.Login)
auth.POST("/logout", handlers.Logout)
auth.GET("/user", handlers.GetUser)
auth.GET("/callback", handlers.AuthCallback)
}
}
// 启动服务器
addr := fmt.Sprintf(":%s", cfg.ServerPort)
if err := r.Run(addr); err != nil {
log.Fatalf("Failed to start server: %v", err)
}
}

View File

@ -0,0 +1,97 @@
package middleware
import (
"database/sql"
"net/http"
"time"
"github.com/gin-gonic/gin"
"aimodels-prices/models"
)
func AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
cookie, err := c.Cookie("session")
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
c.Abort()
return
}
db := c.MustGet("db").(*sql.DB)
var session models.Session
err = db.QueryRow("SELECT id, user_id, expires_at, created_at, updated_at, deleted_at FROM session WHERE id = ?", cookie).Scan(
&session.ID, &session.UserID, &session.ExpiresAt, &session.CreatedAt, &session.UpdatedAt, &session.DeletedAt)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid session"})
c.Abort()
return
}
if session.ExpiresAt.Before(time.Now()) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Session expired"})
c.Abort()
return
}
user, err := session.GetUser(db)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"})
c.Abort()
return
}
c.Set("user", user)
c.Next()
}
}
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not logged in"})
c.Abort()
return
}
if u, ok := user.(*models.User); !ok || u.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Next()
}
}
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
_, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.Abort()
return
}
c.Next()
}
}
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
c.Abort()
return
}
if u, ok := user.(*models.User); !ok || u.Role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"})
c.Abort()
return
}
c.Next()
}
}

14
backend/middleware/db.go Normal file
View File

@ -0,0 +1,14 @@
package middleware
import (
"aimodels-prices/database"
"github.com/gin-gonic/gin"
)
func Database() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set("db", database.DB)
c.Next()
}
}

57
backend/models/price.go Normal file
View File

@ -0,0 +1,57 @@
package models
import (
"time"
)
type Price struct {
ID uint `json:"id"`
Model string `json:"model"`
BillingType string `json:"billing_type"` // tokens or times
ChannelType string `json:"channel_type"`
Currency string `json:"currency"` // USD or CNY
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PriceSource string `json:"price_source"`
Status string `json:"status"` // pending, approved, rejected
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
// 临时字段,用于存储待审核的更新
TempModel *string `json:"temp_model,omitempty"`
TempBillingType *string `json:"temp_billing_type,omitempty"`
TempChannelType *string `json:"temp_channel_type,omitempty"`
TempCurrency *string `json:"temp_currency,omitempty"`
TempInputPrice *float64 `json:"temp_input_price,omitempty"`
TempOutputPrice *float64 `json:"temp_output_price,omitempty"`
TempPriceSource *string `json:"temp_price_source,omitempty"`
UpdatedBy *string `json:"updated_by,omitempty"`
}
// CreatePriceTableSQL 返回创建价格表的 SQL
func CreatePriceTableSQL() string {
return `
CREATE TABLE IF NOT EXISTS price (
id INTEGER PRIMARY KEY AUTOINCREMENT,
model TEXT NOT NULL,
billing_type TEXT NOT NULL,
channel_type TEXT NOT NULL,
currency TEXT NOT NULL,
input_price REAL NOT NULL,
output_price REAL NOT NULL,
price_source TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL,
temp_model TEXT,
temp_billing_type TEXT,
temp_channel_type TEXT,
temp_currency TEXT,
temp_input_price REAL,
temp_output_price REAL,
temp_price_source TEXT,
updated_by TEXT,
FOREIGN KEY (channel_type) REFERENCES provider(id)
)`
}

View File

@ -0,0 +1,25 @@
package models
import "time"
type Provider struct {
ID uint `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy string `json:"created_by"`
}
// CreateProviderTableSQL 返回创建供应商表的 SQL
func CreateProviderTableSQL() string {
return `
CREATE TABLE IF NOT EXISTS provider (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL
)`
}

64
backend/models/user.go Normal file
View File

@ -0,0 +1,64 @@
package models
import (
"database/sql"
"time"
)
type User struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"` // admin or user
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
type Session struct {
ID string `json:"id"`
UserID uint `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// CreateTableSQL 返回创建用户表的 SQL
func CreateUserTableSQL() string {
return `
CREATE TABLE IF NOT EXISTS user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
)`
}
// CreateSessionTableSQL 返回创建会话表的 SQL
func CreateSessionTableSQL() string {
return `
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME,
FOREIGN KEY (user_id) REFERENCES user(id)
)`
}
// GetUser 获取会话关联的用户
func (s *Session) GetUser(db *sql.DB) (*User, error) {
var user User
err := db.QueryRow("SELECT id, username, email, role, created_at, updated_at, deleted_at FROM user WHERE id = ?", s.UserID).Scan(
&user.ID, &user.Username, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt, &user.DeletedAt)
if err != nil {
return nil, err
}
return &user, nil
}

37
backend/router/router.go Normal file
View File

@ -0,0 +1,37 @@
package router
import (
"github.com/gin-gonic/gin"
"aimodels-prices/handlers"
"aimodels-prices/middleware"
)
// SetupRouter 设置路由
func SetupRouter() *gin.Engine {
r := gin.Default()
// 添加数据库中间件
r.Use(middleware.Database())
// 认证相关路由
auth := r.Group("/auth")
{
auth.GET("/status", handlers.GetAuthStatus)
auth.POST("/login", handlers.Login)
auth.POST("/logout", handlers.Logout)
}
// 供应商相关路由
providers := r.Group("/providers")
{
providers.GET("", handlers.GetProviders)
providers.Use(middleware.RequireAuth())
providers.Use(middleware.RequireAdmin())
providers.POST("", handlers.CreateProvider)
providers.PUT("/:id", handlers.UpdateProvider)
providers.DELETE("/:id", handlers.DeleteProvider)
}
return r
}

View File

@ -1,11 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"lib": ["dom", "dom.iterable", "dom.asynciterable", "deno.ns"],
"strict": true
},
"importMap": "./import_map.json",
"tasks": {
"start": "deno run --allow-net --allow-read --allow-env main.ts"
}
}

View File

@ -1,13 +0,0 @@
{
"compilerOptions": {
"allowJs": true,
"lib": ["dom", "dom.iterable", "deno.ns"],
"strict": true,
"types": ["https://deno.land/x/types/deno.ns.d.ts"]
},
"importMap": "import_map.json",
"tasks": {
"start": "deno run --allow-net --allow-read --allow-env main.ts",
"dev": "deno run --watch --allow-net --allow-read --allow-env main.ts"
}
}

View File

@ -1,3 +0,0 @@
export { serve } from "https://deno.land/std@0.208.0/http/server.ts";
export { crypto } from "https://deno.land/std@0.208.0/crypto/mod.ts";
export { decode as base64Decode } from "https://deno.land/std@0.208.0/encoding/base64.ts";

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

26
frontend/index.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="专业的AI模型价格管理系统支持多供应商、多币种的价格管理提供标准的API接口。">
<meta name="keywords" content="AI模型,价格管理,API接口,OpenAI,Azure,Anthropic">
<meta name="author" content="AI Models Prices Team">
<title>AI模型价格</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f7fa;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1482
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

@ -0,0 +1,20 @@
{
"name": "aimodels-prices-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.7",
"element-plus": "^2.5.3",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"vite": "^5.0.12"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

171
frontend/src/App.vue Normal file
View File

@ -0,0 +1,171 @@
<template>
<el-container>
<el-header height="60px">
<div class="nav-container">
<div class="nav-left">
<router-link to="/" class="logo">
AI模型价格
</router-link>
<div class="nav-buttons">
<el-button @click="$router.push('/prices')" :type="$route.path === '/prices' ? 'primary' : ''">价格列表</el-button>
<el-button @click="$router.push('/providers')" :type="$route.path === '/providers' ? 'primary' : ''">供应商列表</el-button>
</div>
</div>
<div class="auth-buttons">
<template v-if="globalUser">
<span class="user-info">
<el-icon><User /></el-icon>
{{ globalUser.username }}
<el-tag v-if="globalUser.role === 'admin'" size="small" type="success">管理员</el-tag>
</span>
<el-button @click="handleLogout">退出</el-button>
</template>
<el-button v-else type="primary" @click="$router.push('/login')">登录</el-button>
</div>
</div>
</el-header>
<el-main>
<div class="content-container">
<router-view v-slot="{ Component }">
<component :is="Component" :user="globalUser" />
</router-view>
</div>
</el-main>
<el-footer height="60px">
<div class="footer-content">
<p>© 2024 AI模型价格 | <a href="https://github.com" target="_blank">GitHub</a></p>
</div>
</el-footer>
</el-container>
</template>
<script setup>
import { ref, onMounted, provide } from 'vue'
import { User } from '@element-plus/icons-vue'
import axios from 'axios'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const globalUser = ref(null)
const updateGlobalUser = (user) => {
globalUser.value = user
}
provide('updateGlobalUser', updateGlobalUser)
onMounted(async () => {
try {
const { data } = await axios.get('/api/auth/status')
globalUser.value = data.user
} catch (error) {
console.error('Failed to get auth status:', error)
}
})
const handleLogout = async () => {
try {
await axios.post('/api/auth/logout')
globalUser.value = null
router.push('/login')
} catch (error) {
console.error('Failed to logout:', error)
}
}
</script>
<style>
.el-container {
min-height: 100vh;
}
.el-header {
background-color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: fixed;
width: 100%;
z-index: 100;
padding: 0;
}
.el-main {
padding-top: 80px;
background-color: #f5f7fa;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.nav-left {
display: flex;
align-items: center;
gap: 40px;
}
.logo {
font-size: 20px;
font-weight: bold;
color: #409EFF;
text-decoration: none;
white-space: nowrap;
}
.nav-buttons {
display: flex;
gap: 10px;
}
.content-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
}
.auth-buttons {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
color: #606266;
}
.el-footer {
background-color: #fff;
border-top: 1px solid #e4e7ed;
}
.footer-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 20px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
}
.footer-content a {
color: #409EFF;
text-decoration: none;
}
.footer-content a:hover {
text-decoration: underline;
}
</style>

15
frontend/src/main.js Normal file
View File

@ -0,0 +1,15 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import axios from 'axios'
// 配置 axios
axios.defaults.withCredentials = true
const app = createApp(App)
app.use(ElementPlus)
app.use(router)
app.mount('#app')

View File

@ -0,0 +1,33 @@
import { createRouter, createWebHistory } from 'vue-router'
import Prices from '../views/Prices.vue'
import Providers from '../views/Providers.vue'
import Login from '../views/Login.vue'
import Home from '../views/Home.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/prices',
name: 'prices',
component: Prices
},
{
path: '/providers',
name: 'providers',
component: Providers
},
{
path: '/login',
name: 'login',
component: Login
}
]
})
export default router

236
frontend/src/views/Home.vue Normal file
View File

@ -0,0 +1,236 @@
<template>
<div class="home">
<el-card class="intro-card">
<template #header>
<div class="card-header">
<h1>AI模型价格</h1>
</div>
</template>
<div class="content">
<h2>项目简介</h2>
<p>这是一个专门用于管理AI模型价格的系统支持多供应商多币种的价格管理并提供标准的API接口供其他系统调用</p>
<h2>主要功能</h2>
<ul>
<li>供应商管理添加编辑和删除AI模型供应商</li>
<li>价格管理设置和更新各个模型的价格</li>
<li>多币种支持支持USD和CNY两种货币</li>
<li>审核流程价格变更需要管理员审核</li>
<li>API接口提供标准的REST API</li>
</ul>
<h2>API文档</h2>
<el-collapse>
<el-collapse-item title="获取价格倍率">
<div class="api-doc">
<div class="api-url">
<span class="method">GET</span>
<el-tooltip content="点击复制" placement="top">
<span class="url" @click="copyToClipboard(origin + '/api/prices/rates')">
{{ origin }}/api/prices/rates
</span>
</el-tooltip>
</div>
<p>获取所有已审核通过的价格的倍率信息</p>
<h4>响应示例</h4>
<pre>
[
{
"model": "babbage-002",
"type": "tokens",
"channel_type": 1,
"input": 0.2,
"output": 0.2
}
]</pre>
<h4>字段说明</h4>
<ul>
<li>model: 模型名称</li>
<li>type: 计费类型tokens/times</li>
<li>channel_type: 供应商ID</li>
<li>input: 输入价格倍率</li>
<li>output: 输出价格倍率</li>
</ul>
</div>
</el-collapse-item>
<el-collapse-item title="获取价格列表">
<div class="api-doc">
<div class="api-url">
<span class="method">GET</span>
<el-tooltip content="点击复制" placement="top">
<span class="url" @click="copyToClipboard(origin + '/api/prices')">
{{ origin }}/api/prices
</span>
</el-tooltip>
</div>
<p>获取所有价格信息包括待审核的价格</p>
<h4>响应示例</h4>
<pre>
[
{
"id": 1,
"model": "gpt-4",
"billing_type": "tokens",
"channel_type": "1",
"currency": "USD",
"input_price": 0.01,
"output_price": 0.03,
"price_source": "官方",
"status": "approved"
}
]</pre>
</div>
</el-collapse-item>
<el-collapse-item title="获取供应商列表">
<div class="api-doc">
<div class="api-url">
<span class="method">GET</span>
<el-tooltip content="点击复制" placement="top">
<span class="url" @click="copyToClipboard(origin + '/api/providers')">
{{ origin }}/api/providers
</span>
</el-tooltip>
</div>
<p>获取所有供应商信息</p>
<h4>响应示例</h4>
<pre>
[
{
"id": 1,
"name": "OpenAI",
"icon": "https://example.com/openai.png",
"created_at": "2024-01-01T00:00:00Z",
"updated_at": "2024-01-01T00:00:00Z",
"created_by": "admin"
}
]</pre>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</div>
</template>
<style scoped>
.home {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.intro-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
margin: 0;
font-size: 24px;
color: #409EFF;
}
h2 {
margin-top: 30px;
margin-bottom: 15px;
font-size: 20px;
color: #303133;
}
.content {
line-height: 1.6;
color: #606266;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 8px;
}
.api-doc {
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
}
pre {
background-color: #f1f1f1;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
}
h4 {
margin: 15px 0 10px;
color: #303133;
}
.api-url {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
background-color: #f1f1f1;
padding: 8px 12px;
border-radius: 4px;
}
.method {
color: #67C23A;
font-weight: bold;
background-color: #f0f9eb;
padding: 2px 8px;
border-radius: 4px;
font-size: 14px;
}
.url {
color: #409EFF;
cursor: pointer;
font-family: monospace;
font-size: 14px;
}
.url:hover {
text-decoration: underline;
}
</style>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
const origin = ref('')
onMounted(() => {
origin.value = window.location.origin
})
//
const meta = {
title: 'AI模型价格 - 首页',
description: '专业的AI模型价格管理系统支持多供应商、多币种的价格管理提供标准的API接口。',
keywords: 'AI模型,价格管理,API接口,OpenAI,Azure,Anthropic'
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制到剪贴板')
} catch (err) {
ElMessage.error('复制失败')
}
}
defineExpose({ meta })
</script>

View File

@ -0,0 +1,62 @@
<template>
<div class="login">
<el-card class="login-card">
<template #header>
<h2>登录</h2>
</template>
<el-button type="primary" @click="handleLogin" :loading="loading">
{{ loading ? '登录中...' : '登录' }}
</el-button>
</el-card>
</div>
</template>
<script setup>
import { ref, inject } from 'vue'
import axios from 'axios'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const updateGlobalUser = inject('updateGlobalUser')
const handleLogin = async () => {
loading.value = true
try {
await axios.post('/api/auth/login')
const { data } = await axios.get('/api/auth/status')
updateGlobalUser(data.user)
ElMessage.success('登录成功')
router.push('/prices')
} catch (error) {
console.error('Failed to login:', error)
ElMessage.error('登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100vh - 60px);
}
.login-card {
width: 100%;
max-width: 400px;
}
h2 {
text-align: center;
margin: 0;
}
.el-button {
width: 100%;
}
</style>

View File

@ -0,0 +1,483 @@
<template>
<div class="prices">
<el-card>
<template #header>
<div class="card-header">
<div class="header-left">
<span>价格列表</span>
</div>
<el-button type="primary" @click="handleAdd">提交价格</el-button>
</div>
</template>
<div class="filter-section">
<div class="filter-label">厂商筛选:</div>
<div class="provider-filters">
<el-button
:type="!selectedProvider ? 'primary' : ''"
@click="selectedProvider = ''"
>全部</el-button>
<el-button
v-for="provider in providers"
:key="provider.id"
:type="selectedProvider === provider.id.toString() ? 'primary' : ''"
@click="selectedProvider = provider.id.toString()"
>
<div style="display: flex; align-items: center; gap: 8px">
<el-image
v-if="provider.icon"
:src="provider.icon"
style="width: 16px; height: 16px"
/>
<span>{{ provider.name }}</span>
</div>
</el-button>
</div>
</div>
<el-table :data="filteredPrices" style="width: 100%">
<el-table-column label="模型">
<template #default="{ row }">
<div>
<div>{{ row.model }}</div>
<div v-if="row.temp_model" class="pending-update">
待审核: {{ row.temp_model }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="计费类型">
<template #default="{ row }">
<div>
<div>{{ getBillingType(row.billing_type) }}</div>
<div v-if="row.temp_billing_type" class="pending-update">
待审核: {{ getBillingType(row.temp_billing_type) }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="模型厂商">
<template #default="{ row }">
<div>
<div style="display: flex; align-items: center; gap: 8px">
<el-image
v-if="getProvider(row.channel_type)?.icon"
:src="getProvider(row.channel_type)?.icon"
style="width: 24px; height: 24px"
/>
<span>{{ getProvider(row.channel_type)?.name || row.channel_type }}</span>
</div>
<div v-if="row.temp_channel_type" class="pending-update">
待审核: {{ getProvider(row.temp_channel_type)?.name || row.temp_channel_type }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="货币">
<template #default="{ row }">
<div>
<div>{{ row.currency }}</div>
<div v-if="row.temp_currency" class="pending-update">
待审核: {{ row.temp_currency }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="输入价格(M)">
<template #default="{ row }">
<div>
<div>{{ row.input_price }}</div>
<div v-if="row.temp_input_price !== null" class="pending-update">
待审核: {{ row.temp_input_price }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="输出价格(M)">
<template #default="{ row }">
<div>
<div>{{ row.output_price }}</div>
<div v-if="row.temp_output_price !== null" class="pending-update">
待审核: {{ row.temp_output_price }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="输入倍率">
<template #default="{ row }">
{{ calculateRate(row.input_price, row.currency) }}
</template>
</el-table-column>
<el-table-column label="输出倍率">
<template #default="{ row }">
{{ calculateRate(row.output_price, row.currency) }}
</template>
</el-table-column>
<el-table-column label="价格来源">
<template #default="{ row }">
<div>
<div>{{ row.price_source }}</div>
<div v-if="row.temp_price_source" class="pending-update">
待审核: {{ row.temp_price_source }}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态">
<template #default="{ row }">
{{ getStatus(row.status) }}
</template>
</el-table-column>
<el-table-column prop="created_by" label="创建者" />
<el-table-column v-if="isAdmin" label="操作" width="200">
<template #default="{ row }">
<el-button-group>
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<el-button type="success" size="small" @click="updateStatus(row.id, 'approved')" :disabled="row.status !== 'pending'">通过</el-button>
<el-button type="danger" size="small" @click="updateStatus(row.id, 'rejected')" :disabled="row.status !== 'pending'">拒绝</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" title="提交价格">
<el-form :model="form" label-width="120px">
<el-form-item label="模型">
<el-input v-model="form.model" />
</el-form-item>
<el-form-item label="计费类型">
<el-select v-model="form.billing_type" placeholder="请选择">
<el-option label="按量计费" value="tokens" />
<el-option label="按次计费" value="times" />
</el-select>
</el-form-item>
<el-form-item label="模型厂商">
<el-select v-model="form.channel_type" placeholder="请选择">
<el-option
v-for="provider in providers"
:key="provider.id"
:label="provider.name"
:value="provider.id.toString()"
>
<div style="display: flex; align-items: center; gap: 8px">
<el-image
v-if="provider.icon"
:src="provider.icon"
style="width: 24px; height: 24px"
/>
<span>{{ provider.name }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="货币">
<el-select v-model="form.currency" placeholder="请选择">
<el-option label="美元" value="USD" />
<el-option label="人民币" value="CNY" />
</el-select>
</el-form-item>
<el-form-item label="输入价格(M)">
<el-input-number v-model="form.input_price" :precision="4" :step="0.0001" />
</el-form-item>
<el-form-item label="输出价格(M)">
<el-input-number v-model="form.output_price" :precision="4" :step="0.0001" />
</el-form-item>
<el-form-item label="价格来源">
<el-input v-model="form.price_source" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
const props = defineProps({
user: Object
})
const prices = ref([])
const dialogVisible = ref(false)
const form = ref({
model: '',
billing_type: 'tokens',
channel_type: '',
currency: 'USD',
input_price: 0,
output_price: 0,
price_source: '',
created_by: ''
})
const router = useRouter()
const selectedProvider = ref('')
const isAdmin = computed(() => props.user?.role === 'admin')
const providers = ref([])
const getProvider = (id) => providers.value.find(p => p.id.toString() === id)
const statusMap = {
'pending': '待审核',
'approved': '已通过',
'rejected': '已拒绝'
}
const billingTypeMap = {
'tokens': '按量计费',
'times': '按次计费'
}
const getStatus = (status) => statusMap[status] || status
const getBillingType = (type) => billingTypeMap[type] || type
const calculateRate = (price, currency) => {
if (!price) return 0
return currency === 'USD' ? (price / 2).toFixed(4) : (price / 14).toFixed(4)
}
const filteredPrices = computed(() => {
if (!selectedProvider.value) return prices.value
return prices.value.filter(p => p.channel_type === selectedProvider.value)
})
const editingPrice = ref(null)
const loadPrices = async () => {
try {
const { data } = await axios.get('/api/prices')
prices.value = data
} catch (error) {
console.error('Failed to load prices:', error)
ElMessage.error('加载数据失败')
}
}
const handleEdit = (price) => {
editingPrice.value = price
form.value = { ...price }
dialogVisible.value = true
}
const handleDelete = (price) => {
ElMessageBox.confirm(
'确定要删除这个价格吗?',
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await axios.delete(`/api/prices/${price.id}`)
await loadPrices()
ElMessage.success('删除成功')
} catch (error) {
console.error('Failed to delete price:', error)
if (error.response?.data?.error) {
ElMessage.error(error.response.data.error)
} else {
ElMessage.error('删除失败')
}
}
})
}
const handleAdd = () => {
if (!props.user) {
router.push('/login')
ElMessage.warning('请先登录')
return
}
editingPrice.value = null
form.value = {
model: '',
billing_type: 'tokens',
channel_type: '',
currency: 'USD',
input_price: 0,
output_price: 0,
price_source: '',
created_by: ''
}
dialogVisible.value = true
}
const submitForm = async () => {
try {
form.value.created_by = props.user.username
let response
if (editingPrice.value) {
//
response = await axios.put(`/api/prices/${editingPrice.value.id}`, form.value)
} else {
//
const existingPrice = prices.value?.find(p =>
p.model === form.value.model &&
p.channel_type === form.value.channel_type
)
if (existingPrice) {
ElMessageBox.confirm(
'该模型价格已存在,是否要更新?',
'提示',
{
confirmButtonText: '更新',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
response = await axios.put(`/api/prices/${existingPrice.id}`, form.value)
handleSubmitResponse(response)
}).catch(() => {
//
})
return
}
//
response = await axios.post('/api/prices', form.value)
}
handleSubmitResponse(response)
} catch (error) {
console.error('Failed to submit price:', error)
if (error.response?.data?.error) {
ElMessage.error(error.response.data.error)
} else {
ElMessage.error('操作失败')
}
}
}
const handleSubmitResponse = async (response) => {
const { data } = response
if (data.error) {
ElMessage.error(data.error)
return
}
await loadPrices()
dialogVisible.value = false
ElMessage.success(editingPrice.value ? '更新成功' : '添加成功')
editingPrice.value = null
form.value = {
model: '',
billing_type: 'tokens',
channel_type: '',
currency: 'USD',
input_price: 0,
output_price: 0,
price_source: '',
created_by: ''
}
}
const updateStatus = async (id, status) => {
try {
const { data } = await axios.put(`/api/prices/${id}/status`, { status })
await loadPrices()
ElMessage.success(data.message || '更新成功')
} catch (error) {
console.error('Failed to update status:', error)
if (error.response?.data?.error) {
ElMessage.error(error.response.data.error)
} else if (error.response?.status === 401) {
ElMessage.error('请先登录')
router.push('/login')
} else if (error.response?.status === 403) {
ElMessage.error('需要管理员权限')
} else {
ElMessage.error('更新失败')
}
}
}
onMounted(async () => {
await loadPrices()
try {
const { data } = await axios.get('/api/providers')
providers.value = data
} catch (error) {
console.error('Failed to load providers:', error)
ElMessage.error('加载供应商数据失败')
}
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
}
.filter-section {
margin: 16px 0;
display: flex;
align-items: center;
gap: 12px;
}
.filter-label {
font-size: 14px;
color: #606266;
}
.provider-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
:deep(.el-button) {
margin: 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
:deep(.el-dialog__body) {
padding-right: 20px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
:deep(.el-dialog) {
margin: 0 !important;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.prices {
padding-right: 0;
}
.pending-update {
font-size: 12px;
color: #E6A23C;
margin-top: 4px;
padding: 2px 4px;
background-color: #FDF6EC;
border-radius: 2px;
}
</style>

View File

@ -0,0 +1,217 @@
<template>
<div class="providers">
<el-card>
<template #header>
<div class="card-header">
<span>供应商列表</span>
<el-button type="primary" @click="handleAdd">添加供应商</el-button>
</div>
</template>
<el-table :data="sortedProviders" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="名称">
<template #default="{ row }">
{{ row.name }}
</template>
</el-table-column>
<el-table-column label="图标" width="100">
<template #default="{ row }">
<el-image
v-if="row.icon"
:src="row.icon"
style="width: 24px; height: 24px"
:preview-src-list="[row.icon]"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="created_by" label="创建者" />
<el-table-column v-if="isAdmin" label="操作" width="200">
<template #default="{ row }">
<el-button-group>
<el-button type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle">
<el-form :model="form" label-width="80px">
<el-form-item label="ID" v-if="!editingProvider">
<el-input-number v-model="form.id" :min="1" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="图标链接">
<el-input v-model="form.icon" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import axios from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
const props = defineProps({
user: Object
})
const providers = ref([])
const dialogVisible = ref(false)
const editingProvider = ref(null)
const form = ref({
id: 1,
name: '',
icon: ''
})
const router = useRouter()
const isAdmin = computed(() => props.user?.role === 'admin')
// ID
const sortedProviders = computed(() => {
return [...providers.value].sort((a, b) => a.id - b.id)
})
const loadProviders = async () => {
try {
const { data } = await axios.get('/api/providers')
providers.value = Array.isArray(data) ? data : []
} catch (error) {
console.error('Failed to load providers:', error)
ElMessage.error('加载数据失败')
}
}
onMounted(() => {
loadProviders()
})
const dialogTitle = computed(() => {
if (editingProvider.value) {
return '编辑供应商'
}
return '添加供应商'
})
const handleEdit = (provider) => {
if (!isAdmin.value) {
ElMessage.warning('只有管理员可以编辑供应商信息')
return
}
editingProvider.value = provider
form.value = { ...provider }
dialogVisible.value = true
}
const handleDelete = (provider) => {
if (!isAdmin.value) {
ElMessage.warning('只有管理员可以删除供应商')
return
}
ElMessageBox.confirm(
'确定要删除这个供应商吗?',
'警告',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
).then(async () => {
try {
await axios.delete(`/api/providers/${provider.id}`)
providers.value = providers.value.filter(p => p.id !== provider.id)
ElMessage.success('删除成功')
} catch (error) {
console.error('Failed to delete provider:', error)
if (error.response?.status === 403) {
ElMessage.error('没有权限执行此操作')
} else {
ElMessage.error('删除失败')
}
}
})
}
const handleAdd = () => {
if (!props.user) {
router.push('/login')
ElMessage.warning('请先登录')
return
}
dialogVisible.value = true
form.value = { id: 1, name: '', icon: '' }
}
const submitForm = async () => {
try {
if (editingProvider.value) {
if (!isAdmin.value) {
ElMessage.error('只有管理员可以编辑供应商信息')
return
}
//
const { data } = await axios.put(`/api/providers/${editingProvider.value.id}`, form.value)
if (data.error) {
ElMessage.error(data.error)
return
}
const index = providers.value.findIndex(p => p.id === editingProvider.value.id)
if (index !== -1) {
providers.value[index] = data
}
ElMessage.success('更新成功')
} else {
//
const { data } = await axios.post('/api/providers', form.value)
if (data.error) {
ElMessage.error(data.error)
return
}
providers.value = providers.value ? [...providers.value, data] : [data]
ElMessage.success('添加成功')
}
dialogVisible.value = false
editingProvider.value = null
form.value = { id: 1, name: '', icon: '' }
} catch (error) {
console.error('Failed to submit provider:', error)
if (error.response?.data?.error) {
ElMessage.error(error.response.data.error)
} else if (error.response?.status === 403) {
ElMessage.error('没有权限执行此操作')
} else {
ElMessage.error('操作失败')
}
}
}
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
</style>

15
frontend/vite.config.js Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
publicDir: 'public',
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})

View File

@ -1,6 +0,0 @@
{
"imports": {
"std/": "https://deno.land/std@0.220.1/",
"deno/": "https://deno.land/x/deno@v1.40.5/"
}
}

1261
main.ts

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

10
scripts/start.sh Normal file
View File

@ -0,0 +1,10 @@
#!/bin/sh
# 启动后端服务
./main &
# 启动前端服务
cd /app/frontend && node server.js &
# 启动 nginx
nginx -g 'daemon off;'