Merge branch 'alipay'

This commit is contained in:
RockYang 2023-11-10 16:51:00 +08:00
commit 05f501af52
61 changed files with 4119 additions and 304 deletions

View File

@ -1,8 +1,9 @@
# 更新日志
## v3.1.7
1. Bug修复修复 MidJourney API 参数版本更新导致调用失败的 Bug
2. 功能优化:将聊天报错信息定义为统一常量,方便修改
## v3.1.8
1. 功能新增:新增会员套餐充值,点卡充值,订单系统,集成支付宝支付通道
2. Bug修复修复 MidJourney API 参数版本更新导致调用失败的 Bug
3. 功能优化:将聊天报错信息定义为统一常量,方便修改
## v3.1.7
1. 功能新增支持文心4.0 AI 模型

View File

@ -8,7 +8,8 @@ ChatGLM,讯飞星火,文心一言等多个平台的大语言模型。集成了
* 内置了各种预训练好的角色应用,比如小红书写手,英语翻译大师,苏格拉底,孔子,乔布斯,周报助手等。轻松满足你的各种聊天和应用需求。
* 支持 OPenAIAzure文心一言讯飞星火清华 ChatGLM等多个大语言模型。
* 支持 MidJourney / Stable Diffusion AI 绘画集成,开箱即用。
* 支持使用个人微信二维码作为充值收费的支付渠道,无需企业支付通道。(可定制开发其他支付通道支持)
* 支持使用个人微信二维码作为充值收费的支付渠道,无需企业支付通道。
* 已集成支付宝支付功能,支持多种会员套餐和点卡购买功能。
* 集成插件 API 功能,可结合大语言模型的 function 功能开发各种强大的插件,已内置实现了微博热搜,今日头条,今日早报和 AI 绘画函数插件。
## 功能截图
@ -133,7 +134,7 @@ cd docker/mysql
# 创建 mysql 容器
docker-compose up -d
# 导入数据库
docker exec -i chatgpt-plus-mysql sh -c 'exec mysql -uroot -p12345678' < ../../database/chatgpt_plus-v3.1.7.sql
docker exec -i chatgpt-plus-mysql sh -c 'exec mysql -uroot -p12345678' < ../../database/chatgpt_plus-v3.1.8.sql
```
如果你本地已经安装了 MySQL 服务,那么你只需手动导入数据库即可。
@ -218,6 +219,25 @@ WeChatBot = false # 是否启动微信机器人
ApiURL = "http://172.22.11.200:7860" # stable-diffusion-webui API 地址
ApiKey = "" # 如果开启了授权,这里需要配置授权的 ApiKey
Txt2ImgJsonPath = "res/text2img.json" # 文生图的 API 请求报文 json 模板允许自定义请求json报文因为不同版本的 API 绘图的参数以及 fn_index 会不同。
[XXLConfig] # xxl-job 配置,需要你部署 XXL-JOB 定时任务工具,用来定期清理未支付订单和清理过期 VIP如果你没有启用支付服务则该服务也无需启动
Enabled = false # 是否启用 XXL JOB 服务
ServerAddr = "http://172.22.11.47:8080/xxl-job-admin" # xxl-job-admin 管理地址
ExecutorIp = "172.22.11.47" # 执行器 IP 地址
ExecutorPort = "9999" # 执行器服务端口
AccessToken = "xxl-job-api-token" # 执行器 API 通信 token
RegistryKey = "chatgpt-plus" # 任务注册 key
[AlipayConfig]
Enabled = false # 启用支付宝支付通道
SandBox = false # 是否启用沙盒模式
UserId = "2088721020750581" # 商户ID
AppId = "9021000131658023" # App Id
PrivateKey = "certs/alipay/privateKey.txt" # 应用私钥
PublicKey = "certs/alipay/appPublicCert.crt" # 应用公钥证书
AlipayPublicKey = "certs/alipay/alipayPublicCert.crt" # 支付宝公钥证书
RootCert = "certs/alipay/alipayRootCert.crt" # 支付宝根证书
NotifyURL = "http://r9it.com:6004/api/payment/alipay/notify" # 支付异步回调地址
```
> 1. 如果你不知道如何获取 Discord 用户 Token 和 Bot Token
@ -259,7 +279,7 @@ version: '3'
services:
# 后端 API 镜像
chatgpt-plus-api:
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/chatgpt-plus-api:v3.1.5 #这里改成最新的 release 版本地址
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/chatgpt-plus-api:v3.1.8 #这里改成最新的 release 版本
container_name: chatgpt-plus-api
restart: always
environment:
@ -276,7 +296,7 @@ services:
# 前端应用镜像
chatgpt-plus-web:
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/chatgpt-plus-web:v3.1.5 #这里改成最新的 release 版本地址
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/chatgpt-plus-web:v3.1.8 #这里改成最新的 release 版本
container_name: chatgpt-plus-web
restart: always
ports:

1
api/.gitignore vendored
View File

@ -18,3 +18,4 @@ data
config.toml
static/upload
storage.json
certs/alipay/*

View File

@ -68,3 +68,22 @@ WeChatBot = false
ApiURL = "http://172.22.11.200:7860"
ApiKey = ""
Txt2ImgJsonPath = "res/text2img.json"
[XXLConfig] # xxl-job 配置,需要你部署 XXL-JOB 定时任务工具,用来定期清理未支付订单和清理过期 VIP如果你没有启用支付服务则该服务也无需启动
Enabled = false # 是否启用 XXL JOB 服务
ServerAddr = "http://172.22.11.47:8080/xxl-job-admin" # xxl-job-admin 管理地址
ExecutorIp = "172.22.11.47" # 执行器 IP 地址
ExecutorPort = "9999" # 执行器服务端口
AccessToken = "xxl-job-api-token" # 执行器 API 通信 token
RegistryKey = "chatgpt-plus" # 任务注册 key
[AlipayConfig]
Enabled = false # 启用支付宝支付通道
SandBox = false # 是否启用沙盒模式
UserId = "2088721020750581" # 商户ID
AppId = "9021000131658023" # App Id
PrivateKey = "certs/alipay/privateKey.txt" # 应用私钥
PublicKey = "certs/alipay/appPublicCert.crt" # 应用公钥证书
AlipayPublicKey = "certs/alipay/alipayPublicCert.crt" # 支付宝公钥证书
RootCert = "certs/alipay/alipayRootCert.crt" # 支付宝根证书
NotifyURL = "http://r9it.com:6004/api/payment/alipay/notify" # 支付异步回调地址

View File

@ -151,6 +151,7 @@ func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc {
c.Request.URL.Path == "/api/sd/jobs" ||
strings.HasPrefix(c.Request.URL.Path, "/api/sms/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/captcha/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/payment/") ||
strings.HasPrefix(c.Request.URL.Path, "/static/") ||
c.Request.URL.Path == "/api/admin/config/get" {
c.Next()

View File

@ -36,6 +36,7 @@ func NewDefaultConfig() *types.AppConfig {
MjConfig: types.MidJourneyConfig{Enabled: false},
SdConfig: types.StableDiffusionConfig{Enabled: false, Txt2ImgJsonPath: "res/text2img.json"},
WeChatBot: false,
AlipayConfig: types.AlipayConfig{Enabled: false, SandBox: false},
}
}

View File

@ -21,6 +21,9 @@ type AppConfig struct {
MjConfig MidJourneyConfig // mj 绘画配置
WeChatBot bool // 是否启用微信机器人
SdConfig StableDiffusionConfig // sd 绘画配置
XXLConfig XXLConfig
AlipayConfig AlipayConfig
}
type ChatPlusApiConfig struct {
@ -57,6 +60,27 @@ type AliYunSmsConfig struct {
CodeTempId string // 验证码短信模板 ID
}
type AlipayConfig struct {
Enabled bool // 是否启用该服务
SandBox bool // 是否沙盒环境
AppId string // 应用 ID
UserId string // 支付宝用户 ID
PrivateKey string // 用户私钥文件路径
PublicKey string // 用户公钥文件路径
AlipayPublicKey string // 支付宝公钥文件路径
RootCert string // Root 秘钥路径
NotifyURL string // 异步通知回调
}
type XXLConfig struct { // XXL 任务调度配置
Enabled bool
ServerAddr string
ExecutorIp string
ExecutorPort string
AccessToken string
RegistryKey string
}
type RedisConfig struct {
Host string
Port int
@ -120,5 +144,7 @@ type SystemConfig struct {
RewardImg string `json:"reward_img"` // 众筹收款二维码地址
EnabledFunction bool `json:"enabled_function"` // 启用 API 函数功能
EnabledReward bool `json:"enabled_reward"` // 启用众筹功能
EnabledAlipay bool `json:"enabled_alipay"` // 是否启用支付宝支付通道
OrderPayTimeout int `json:"order_pay_timeout"` //订单支付超时时间
DefaultModels []string `json:"default_models"` // 默认开通的 AI 模型
}

17
api/core/types/order.go Normal file
View File

@ -0,0 +1,17 @@
package types
type OrderStatus int
const (
OrderNotPaid = OrderStatus(0)
OrderScanned = OrderStatus(1) // 已扫码
OrderPaidSuccess = OrderStatus(2)
)
type OrderRemark struct {
Days int `json:"days"` // 有效期
Calls int `json:"calls"` // 增加调用次数
Name string `json:"name"` // 产品名称
Price float64 `json:"price"`
Discount float64 `json:"discount"`
}

View File

@ -18,12 +18,15 @@ require (
github.com/pkoukk/tiktoken-go v0.1.1-0.20230418101013-cae809389480
github.com/qiniu/go-sdk/v7 v7.17.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartwalle/alipay/v3 v3.2.15
github.com/syndtr/goleveldb v1.0.0
go.uber.org/zap v1.23.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.4.7
)
require github.com/xxl-job/xxl-job-executor-go v1.2.0
require (
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/bytedance/sonic v1.9.1 // indirect
@ -34,6 +37,7 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gaukas/godicttls v0.0.3 // indirect
github.com/go-basic/ipv4 v1.0.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/goccy/go-json v0.10.2 // indirect
@ -49,6 +53,7 @@ require (
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646
github.com/onsi/ginkgo/v2 v2.10.0 // indirect
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
@ -59,6 +64,9 @@ require (
github.com/refraction-networking/utls v1.3.2 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/smartwalle/ncrypto v1.0.2 // indirect
github.com/smartwalle/ngx v1.0.6 // indirect
github.com/smartwalle/nsign v1.0.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
go.uber.org/dig v1.16.1 // indirect
golang.org/x/arch v0.3.0 // indirect

View File

@ -39,6 +39,8 @@ 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-basic/ipv4 v1.0.0 h1:gjyFAa1USC1hhXTkPOwBWDPfMcUaIM+tvo1XzV9EZxs=
github.com/go-basic/ipv4 v1.0.0/go.mod h1:etLBnaxbidQfuqE6wgZQfs38nEWNmzALkxDZe4xY8Dg=
github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -133,6 +135,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
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/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@ -175,6 +179,14 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smartwalle/alipay/v3 v3.2.15 h1:3fvFJnINKKAOXHR/Iv20k1Z7KJ+nOh3oK214lELPqG8=
github.com/smartwalle/alipay/v3 v3.2.15/go.mod h1:niTNB609KyUYuAx9Bex/MawEjv2yPx4XOjxSAkqmGjE=
github.com/smartwalle/ncrypto v1.0.2 h1:pTAhCqtPCMhpOwFXX+EcMdR6PNzruBNoGQrN2S1GbGI=
github.com/smartwalle/ncrypto v1.0.2/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
github.com/smartwalle/ngx v1.0.6 h1:JPNqNOIj+2nxxFtrSkJO+vKJfeNUSEQueck/Wworjps=
github.com/smartwalle/ngx v1.0.6/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
github.com/smartwalle/nsign v1.0.8 h1:78KWtwKPrdt4Xsn+tNEBVxaTLIJBX9YRX0ZSrMUeuHo=
github.com/smartwalle/nsign v1.0.8/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
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=
@ -197,6 +209,8 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xxl-job/xxl-job-executor-go v1.2.0 h1:MTl2DpwrK2+hNjRRks2k7vB3oy+3onqm9OaSarneeLQ=
github.com/xxl-job/xxl-job-executor-go v1.2.0/go.mod h1:bUFhz/5Irp9zkdYk5MxhQcDDT6LlZrI8+rv5mHtQ1mo=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=

View File

@ -0,0 +1,93 @@
package admin
import (
"chatplus/core"
"chatplus/core/types"
"chatplus/handler"
"chatplus/store/model"
"chatplus/store/vo"
"chatplus/utils"
"chatplus/utils/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type OrderHandler struct {
handler.BaseHandler
db *gorm.DB
}
func NewOrderHandler(app *core.AppServer, db *gorm.DB) *OrderHandler {
h := OrderHandler{db: db}
h.App = app
return &h
}
func (h *OrderHandler) List(c *gin.Context) {
var data struct {
OrderNo string `json:"order_no"`
PayTime []string `json:"pay_time"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
session := h.db.Session(&gorm.Session{})
if data.OrderNo != "" {
session = session.Where("order_no", data.OrderNo)
}
if len(data.PayTime) == 2 {
start := utils.Str2stamp(data.PayTime[0] + " 00:00:00")
end := utils.Str2stamp(data.PayTime[1] + " 00:00:00")
session = session.Where("pay_time >= ? AND pay_time <= ?", start, end)
}
var total int64
session.Model(&model.Order{}).Count(&total)
var items []model.Order
var list = make([]vo.Order, 0)
offset := (data.Page - 1) * data.PageSize
res := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&items)
if res.Error == nil {
for _, item := range items {
var order vo.Order
err := utils.CopyObject(item, &order)
if err == nil {
order.Id = item.Id
order.CreatedAt = item.CreatedAt.Unix()
order.UpdatedAt = item.UpdatedAt.Unix()
list = append(list, order)
} else {
logger.Error(err)
}
}
}
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, list))
}
func (h *OrderHandler) Remove(c *gin.Context) {
id := h.GetInt(c, "id", 0)
if id > 0 {
var item model.Order
res := h.db.First(&item, id)
if res.Error != nil {
resp.ERROR(c, "记录不存在!")
return
}
if item.Status == types.OrderPaidSuccess {
resp.ERROR(c, "已支付订单不允许删除!")
return
}
res = h.db.Where("id = ?", id).Delete(&model.Order{})
if res.Error != nil {
resp.ERROR(c, "更新数据库失败!")
return
}
}
resp.SUCCESS(c)
}

View File

@ -0,0 +1,144 @@
package admin
import (
"chatplus/core"
"chatplus/core/types"
"chatplus/handler"
"chatplus/store/model"
"chatplus/store/vo"
"chatplus/utils"
"chatplus/utils/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"time"
)
type ProductHandler struct {
handler.BaseHandler
db *gorm.DB
}
func NewProductHandler(app *core.AppServer, db *gorm.DB) *ProductHandler {
h := ProductHandler{db: db}
h.App = app
return &h
}
func (h *ProductHandler) Save(c *gin.Context) {
var data struct {
Id uint `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Discount float64 `json:"discount"`
Enabled bool `json:"enabled"`
Days int `json:"days"`
Calls int `json:"calls"`
CreatedAt int64 `json:"created_at"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
item := model.Product{Name: data.Name, Price: data.Price, Discount: data.Discount, Days: data.Days, Calls: data.Calls, Enabled: data.Enabled}
item.Id = data.Id
if item.Id > 0 {
item.CreatedAt = time.Unix(data.CreatedAt, 0)
}
res := h.db.Save(&item)
if res.Error != nil {
resp.ERROR(c, "更新数据库失败!")
return
}
var itemVo vo.Product
err := utils.CopyObject(item, &itemVo)
if err != nil {
resp.ERROR(c, "数据拷贝失败!")
return
}
itemVo.Id = item.Id
itemVo.UpdatedAt = item.UpdatedAt.Unix()
resp.SUCCESS(c, itemVo)
}
// List 模型列表
func (h *ProductHandler) List(c *gin.Context) {
session := h.db.Session(&gorm.Session{})
enable := h.GetBool(c, "enable")
if enable {
session = session.Where("enabled", enable)
}
var items []model.Product
var list = make([]vo.Product, 0)
res := session.Order("sort_num ASC").Find(&items)
if res.Error == nil {
for _, item := range items {
var product vo.Product
err := utils.CopyObject(item, &product)
if err == nil {
product.Id = item.Id
product.CreatedAt = item.CreatedAt.Unix()
product.UpdatedAt = item.UpdatedAt.Unix()
list = append(list, product)
} else {
logger.Error(err)
}
}
}
resp.SUCCESS(c, list)
}
func (h *ProductHandler) Enable(c *gin.Context) {
var data struct {
Id uint `json:"id"`
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
res := h.db.Model(&model.Product{}).Where("id = ?", data.Id).Update("enabled", data.Enabled)
if res.Error != nil {
resp.ERROR(c, "更新数据库失败!")
return
}
resp.SUCCESS(c)
}
func (h *ProductHandler) Sort(c *gin.Context) {
var data struct {
Ids []uint `json:"ids"`
Sorts []int `json:"sorts"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
for index, id := range data.Ids {
res := h.db.Model(&model.Product{}).Where("id = ?", id).Update("sort_num", data.Sorts[index])
if res.Error != nil {
resp.ERROR(c, "更新数据库失败!")
return
}
}
resp.SUCCESS(c)
}
func (h *ProductHandler) Remove(c *gin.Context) {
id := h.GetInt(c, "id", 0)
if id > 0 {
res := h.db.Where("id = ?", id).Delete(&model.Product{})
if res.Error != nil {
resp.ERROR(c, "更新数据库失败!")
return
}
}
resp.SUCCESS(c)
}

View File

@ -242,8 +242,7 @@ func (h *ChatHandler) sendAzureMessage(
}
// 更新用户信息
h.db.Model(&model.User{}).Where("id = ?", userVo.Id).
UpdateColumn("total_tokens", gorm.Expr("total_tokens + ?", totalTokens))
h.incUserTokenFee(userVo.Id, totalTokens)
}
// 保存当前会话

View File

@ -9,7 +9,6 @@ import (
"context"
"encoding/json"
"fmt"
"gorm.io/gorm"
"io"
"net/http"
"strings"
@ -184,8 +183,7 @@ func (h *ChatHandler) sendBaiduMessage(
logger.Error("failed to save reply history message: ", res.Error)
}
// 更新用户信息
h.db.Model(&model.User{}).Where("id = ?", userVo.Id).
UpdateColumn("total_tokens", gorm.Expr("total_tokens + ?", totalTokens))
h.incUserTokenFee(userVo.Id, totalTokens)
}
// 保存当前会话

View File

@ -187,8 +187,14 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
return nil
}
if userVo.Calls < session.Model.Weight {
utils.ReplyMessage(ws, fmt.Sprintf("您当前剩余对话次数(%d已不足以支付当前模型的单次对话需要消耗的对话额度%d", userVo.Calls, session.Model.Weight))
utils.ReplyMessage(ws, ErrImg)
return nil
}
if userVo.Calls <= 0 && userVo.ChatConfig.ApiKeys[session.Model.Platform] == "" {
utils.ReplyMessage(ws, "您的对话次数已经用尽请联系管理员或者点击左下角菜单加入众筹获得100次对话")
utils.ReplyMessage(ws, "您的对话次数已经用尽,请联系管理员或者充值点卡继续对话!")
utils.ReplyMessage(ws, ErrImg)
return nil
}
@ -479,3 +485,10 @@ func (h *ChatHandler) subUserCalls(userVo vo.User, session *types.ChatSession) {
h.db.Model(&model.User{}).Where("id = ?", userVo.Id).UpdateColumn("calls", gorm.Expr("calls - ?", num))
}
}
func (h *ChatHandler) incUserTokenFee(userId uint, tokens int) {
h.db.Model(&model.User{}).Where("id = ?", userId).
UpdateColumn("total_tokens", gorm.Expr("total_tokens + ?", tokens))
h.db.Model(&model.User{}).Where("id = ?", userId).
UpdateColumn("tokens", gorm.Expr("tokens + ?", tokens))
}

View File

@ -10,7 +10,6 @@ import (
"encoding/json"
"fmt"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"io"
"strings"
"time"
@ -164,8 +163,7 @@ func (h *ChatHandler) sendChatGLMMessage(
logger.Error("failed to save reply history message: ", res.Error)
}
// 更新用户信息
h.db.Model(&model.User{}).Where("id = ?", userVo.Id).
UpdateColumn("total_tokens", gorm.Expr("total_tokens + ?", totalTokens))
h.incUserTokenFee(userVo.Id, totalTokens)
}
// 保存当前会话

View File

@ -241,8 +241,7 @@ func (h *ChatHandler) sendOpenAiMessage(
}
// 更新用户信息
h.db.Model(&model.User{}).Where("id = ?", userVo.Id).
UpdateColumn("total_tokens", gorm.Expr("total_tokens + ?", totalTokens))
h.incUserTokenFee(userVo.Id, totalTokens)
}
// 保存当前会话

View File

@ -12,7 +12,6 @@ import (
"encoding/json"
"fmt"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"io"
"net/http"
"net/url"
@ -227,8 +226,7 @@ func (h *ChatHandler) sendXunFeiMessage(
logger.Error("failed to save reply history message: ", res.Error)
}
// 更新用户信息
h.db.Model(&model.User{}).Where("id = ?", userVo.Id).
UpdateColumn("total_tokens", gorm.Expr("total_tokens + ?", totalTokens))
h.incUserTokenFee(userVo.Id, totalTokens)
}
// 保存当前会话

View File

@ -0,0 +1,57 @@
package handler
import (
"chatplus/core"
"chatplus/core/types"
"chatplus/store/model"
"chatplus/store/vo"
"chatplus/utils"
"chatplus/utils/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type OrderHandler struct {
BaseHandler
db *gorm.DB
}
func NewOrderHandler(app *core.AppServer, db *gorm.DB) *OrderHandler {
h := OrderHandler{db: db}
h.App = app
return &h
}
func (h *OrderHandler) List(c *gin.Context) {
var data struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
user, _ := utils.GetLoginUser(c, h.db)
session := h.db.Session(&gorm.Session{}).Where("user_id = ? AND status = ?", user.Id, types.OrderPaidSuccess)
var total int64
session.Model(&model.Order{}).Count(&total)
var items []model.Order
var list = make([]vo.Order, 0)
offset := (data.Page - 1) * data.PageSize
res := session.Order("id DESC").Offset(offset).Limit(data.PageSize).Find(&items)
if res.Error == nil {
for _, item := range items {
var order vo.Order
err := utils.CopyObject(item, &order)
if err == nil {
order.Id = item.Id
order.CreatedAt = item.CreatedAt.Unix()
order.UpdatedAt = item.UpdatedAt.Unix()
list = append(list, order)
} else {
logger.Error(err)
}
}
}
resp.SUCCESS(c, vo.NewPage(total, data.Page, data.PageSize, list))
}

View File

@ -0,0 +1,273 @@
package handler
import (
"chatplus/core"
"chatplus/core/types"
"chatplus/service"
"chatplus/service/payment"
"chatplus/store/model"
"chatplus/utils"
"chatplus/utils/resp"
"embed"
"encoding/base64"
"fmt"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"net/http"
"net/url"
"sync"
"time"
)
const (
PayWayAlipay = "支付宝"
PayWayWechat = "微信支付"
)
// PaymentHandler 支付服务回调 handler
type PaymentHandler struct {
BaseHandler
alipayService *payment.AlipayService
snowflake *service.Snowflake
db *gorm.DB
fs embed.FS
lock sync.Mutex
}
func NewPaymentHandler(server *core.AppServer, alipayService *payment.AlipayService, snowflake *service.Snowflake, db *gorm.DB, fs embed.FS) *PaymentHandler {
h := PaymentHandler{lock: sync.Mutex{}}
h.App = server
h.alipayService = alipayService
h.snowflake = snowflake
h.db = db
h.fs = fs
return &h
}
func (h *PaymentHandler) Alipay(c *gin.Context) {
orderNo := h.GetTrim(c, "order_no")
if orderNo == "" {
resp.ERROR(c, types.InvalidArgs)
return
}
var order model.Order
res := h.db.Where("order_no = ?", orderNo).First(&order)
if res.Error != nil {
resp.ERROR(c, "Order not found")
return
}
// 更新扫码状态
h.db.Model(&order).UpdateColumn("status", types.OrderScanned)
// 生成支付链接
notifyURL := h.App.Config.AlipayConfig.NotifyURL
returnURL := "" // 关闭同步回跳
amount := fmt.Sprintf("%.2f", order.Amount)
uri, err := h.alipayService.PayUrlMobile(order.OrderNo, notifyURL, returnURL, amount, order.Subject)
if err != nil {
resp.ERROR(c, "error with generate pay url: "+err.Error())
return
}
c.Redirect(302, uri)
}
// OrderQuery 清单状态查询
func (h *PaymentHandler) OrderQuery(c *gin.Context) {
var data struct {
OrderNo string `json:"order_no"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
var order model.Order
res := h.db.Where("order_no = ?", data.OrderNo).First(&order)
if res.Error != nil {
resp.ERROR(c, "Order not found")
return
}
if order.Status == types.OrderPaidSuccess {
resp.SUCCESS(c, gin.H{"status": order.Status})
return
}
counter := 0
for {
time.Sleep(time.Second)
var item model.Order
h.db.Where("order_no = ?", data.OrderNo).First(&item)
if counter >= 15 || item.Status == types.OrderPaidSuccess || item.Status != order.Status {
order.Status = item.Status
break
}
counter++
}
resp.SUCCESS(c, gin.H{"status": order.Status})
}
// AlipayQrcode 生成支付宝支付 URL 二维码
func (h *PaymentHandler) AlipayQrcode(c *gin.Context) {
if !h.App.SysConfig.EnabledAlipay || h.alipayService == nil {
resp.ERROR(c, "当前支付通道已经关闭,请联系管理员开通!")
return
}
var data struct {
ProductId uint `json:"product_id"`
UserId int `json:"user_id"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
var product model.Product
res := h.db.First(&product, data.ProductId)
if res.Error != nil {
resp.ERROR(c, "Product not found")
return
}
orderNo, err := h.snowflake.Next()
if err != nil {
resp.ERROR(c, "error with generate trade no: "+err.Error())
return
}
var user model.User
res = h.db.First(&user, data.UserId)
if res.Error != nil {
resp.ERROR(c, "Invalid user ID")
return
}
// 创建订单
remark := types.OrderRemark{
Days: product.Days,
Calls: product.Calls,
Name: product.Name,
Price: product.Price,
Discount: product.Discount,
}
order := model.Order{
UserId: user.Id,
Mobile: user.Mobile,
ProductId: product.Id,
OrderNo: orderNo,
Subject: product.Name,
Amount: product.Price - product.Discount,
Status: types.OrderNotPaid,
PayWay: PayWayAlipay,
Remark: utils.JsonEncode(remark),
}
res = h.db.Create(&order)
if res.Error != nil {
resp.ERROR(c, "error with create order: "+res.Error.Error())
return
}
// 生成二维码图片
file, err := h.fs.Open("res/img/alipay.jpg")
if err != nil {
resp.ERROR(c, err.Error())
return
}
parse, err := url.Parse(h.App.Config.AlipayConfig.NotifyURL)
if err != nil {
resp.ERROR(c, err.Error())
return
}
imageURL := fmt.Sprintf("%s://%s/api/payment/alipay?order_no=%s", parse.Scheme, parse.Host, orderNo)
imgData, err := utils.GenQrcode(imageURL, 400, file)
if err != nil {
resp.ERROR(c, err.Error())
return
}
imgDataBase64 := base64.StdEncoding.EncodeToString(imgData)
resp.SUCCESS(c, gin.H{"order_no": orderNo, "image": fmt.Sprintf("data:image/jpg;base64, %s", imgDataBase64), "url": imageURL})
}
func (h *PaymentHandler) AlipayNotify(c *gin.Context) {
err := c.Request.ParseForm()
if err != nil {
c.String(http.StatusOK, "fail")
return
}
// TODO这里最好用支付宝的公钥签名签证一下交易真假
//res := h.alipayService.TradeVerify(c.Request.Form)
r := h.alipayService.TradeQuery(c.Request.Form.Get("out_trade_no"))
logger.Infof("验证支付结果:%+v", r)
if !r.Success() {
c.String(http.StatusOK, "fail")
return
}
h.lock.Lock()
defer h.lock.Unlock()
var order model.Order
res := h.db.Where("order_no = ?", r.OutTradeNo).First(&order)
if res.Error != nil {
logger.Error(res.Error)
c.String(http.StatusOK, "fail")
return
}
var user model.User
res = h.db.First(&user, order.UserId)
if res.Error != nil {
logger.Error(res.Error)
c.String(http.StatusOK, "fail")
return
}
var remark types.OrderRemark
err = utils.JsonDecode(order.Remark, &remark)
if err != nil {
logger.Error(res.Error)
c.String(http.StatusOK, "fail")
return
}
// 1. 点卡days == 0, calls > 0
// 2. vip 套餐days > 0, calls == 0
if remark.Days > 0 {
if user.ExpiredTime > time.Now().Unix() {
user.ExpiredTime = time.Unix(user.ExpiredTime, 0).AddDate(0, 0, remark.Days).Unix()
} else {
user.ExpiredTime = time.Now().AddDate(0, 0, remark.Days).Unix()
}
user.Vip = true
} else if !user.Vip { // 充值点卡的非 VIP 用户
user.ExpiredTime = time.Now().AddDate(0, 0, 30).Unix()
}
if remark.Calls > 0 { // 充值点卡
user.Calls += remark.Calls
} else {
user.Calls += h.App.SysConfig.VipMonthCalls
}
// 更新用户信息
res = h.db.Updates(&user)
if res.Error != nil {
logger.Error(res.Error)
c.String(http.StatusOK, "fail")
return
}
// 更新订单状态
order.PayTime = time.Now().Unix()
order.Status = types.OrderPaidSuccess
h.db.Updates(&order)
// 更新产品销量
h.db.Model(&model.Product{}).Where("id = ?", order.ProductId).UpdateColumn("sales", gorm.Expr("sales + ?", 1))
c.String(http.StatusOK, "success")
}

View File

@ -0,0 +1,44 @@
package handler
import (
"chatplus/core"
"chatplus/store/model"
"chatplus/store/vo"
"chatplus/utils"
"chatplus/utils/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type ProductHandler struct {
BaseHandler
db *gorm.DB
}
func NewProductHandler(app *core.AppServer, db *gorm.DB) *ProductHandler {
h := ProductHandler{db: db}
h.App = app
return &h
}
// List 模型列表
func (h *ProductHandler) List(c *gin.Context) {
var items []model.Product
var list = make([]vo.Product, 0)
res := h.db.Where("enabled", true).Order("sort_num ASC").Find(&items)
if res.Error == nil {
for _, item := range items {
var product vo.Product
err := utils.CopyObject(item, &product)
if err == nil {
product.Id = item.Id
product.CreatedAt = item.CreatedAt.Unix()
product.UpdatedAt = item.UpdatedAt.Unix()
list = append(list, product)
} else {
logger.Error(err)
}
}
}
resp.SUCCESS(c, list)
}

View File

@ -230,6 +230,9 @@ type userProfile struct {
Calls int `json:"calls"`
ImgCalls int `json:"img_calls"`
TotalTokens int64 `json:"total_tokens"`
Tokens int64 `json:"tokens"`
ExpiredTime int64 `json:"expired_time"`
Vip bool `json:"vip"`
}
func (h *UserHandler) Profile(c *gin.Context) {

View File

@ -11,6 +11,7 @@ import (
"chatplus/service/fun"
"chatplus/service/mj"
"chatplus/service/oss"
"chatplus/service/payment"
"chatplus/service/sd"
"chatplus/service/wx"
"chatplus/store"
@ -32,7 +33,7 @@ import (
var logger = logger2.GetLogger()
//go:embed res/ip2region.xdb
//go:embed res
var xdbFS embed.FS
// AppLifecycle 应用程序生命周期
@ -96,6 +97,10 @@ func main() {
fx.Provide(store.NewLevelDB),
fx.Provide(store.NewRedisClient),
fx.Provide(func() embed.FS {
return xdbFS
}),
// 创建 Ip2Region 查询对象
fx.Provide(func() (*xdb.Searcher, error) {
file, err := xdbFS.Open("res/ip2region.xdb")
@ -124,6 +129,9 @@ func main() {
fx.Provide(handler.NewMidJourneyHandler),
fx.Provide(handler.NewChatModelHandler),
fx.Provide(handler.NewSdJobHandler),
fx.Provide(handler.NewPaymentHandler),
fx.Provide(handler.NewOrderHandler),
fx.Provide(handler.NewProductHandler),
fx.Provide(admin.NewConfigHandler),
fx.Provide(admin.NewAdminHandler),
@ -133,6 +141,8 @@ func main() {
fx.Provide(admin.NewRewardHandler),
fx.Provide(admin.NewDashboardHandler),
fx.Provide(admin.NewChatModelHandler),
fx.Provide(admin.NewProductHandler),
fx.Provide(admin.NewOrderHandler),
// 创建服务
fx.Provide(service.NewAliYunSmsService),
@ -181,6 +191,18 @@ func main() {
}()
}
}),
fx.Provide(payment.NewAlipayService),
fx.Provide(service.NewSnowflake),
fx.Provide(service.NewXXLJobExecutor),
fx.Invoke(func(exec *service.XXLJobExecutor, config *types.AppConfig) {
if config.XXLConfig.Enabled {
go func() {
log.Fatal(exec.Run())
}()
}
}),
// 注册路由
fx.Invoke(func(s *core.AppServer, h *handler.ChatRoleHandler) {
group := s.Engine.Group("/api/role/")
@ -296,6 +318,34 @@ func main() {
group.POST("sort", h.Sort)
group.GET("remove", h.Remove)
}),
fx.Invoke(func(s *core.AppServer, h *handler.PaymentHandler) {
group := s.Engine.Group("/api/payment/")
group.GET("alipay", h.Alipay)
group.POST("query", h.OrderQuery)
group.POST("alipay/qrcode", h.AlipayQrcode)
group.POST("alipay/notify", h.AlipayNotify)
}),
fx.Invoke(func(s *core.AppServer, h *admin.ProductHandler) {
group := s.Engine.Group("/api/admin/product/")
group.POST("save", h.Save)
group.GET("list", h.List)
group.POST("enable", h.Enable)
group.POST("sort", h.Sort)
group.GET("remove", h.Remove)
}),
fx.Invoke(func(s *core.AppServer, h *admin.OrderHandler) {
group := s.Engine.Group("/api/admin/order/")
group.POST("list", h.List)
group.GET("remove", h.Remove)
}),
fx.Invoke(func(s *core.AppServer, h *handler.OrderHandler) {
group := s.Engine.Group("/api/order/")
group.POST("list", h.List)
}),
fx.Invoke(func(s *core.AppServer, h *handler.ProductHandler) {
group := s.Engine.Group("/api/product/")
group.GET("list", h.List)
}),
fx.Invoke(func(s *core.AppServer, db *gorm.DB) {
err := s.Run(db)

BIN
api/res/img/alipay.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,37 +1,38 @@
{
"data": [
"task(38194gitxp745ha)",
"A beautiful Chinese girl riding on a tiger",
"task(m1wpaa4v60zedj8)",
"a cute cat",
"",
[],
20,
"Euler a",
false,
false,
"DPM++ 2M Karras",
1,
1,
7,
-1,
-1,
0,
0,
0,
false,
512,
512,
384,
true,
0.7,
2,
"ESRGAN_4x",
10,
0,
0,
0,
"Use same checkpoint",
"Use same sampler",
"",
"",
[],
"None",
null,
false,
"",
0.8,
-1,
false,
-1,
0,
0,
0,
false,
false,
"positive",
@ -54,45 +55,13 @@
false,
false,
0,
"Not set",
true,
true,
"",
"",
"",
"",
"",
1.3,
"Not set",
"Not set",
1.3,
"Not set",
1.3,
"Not set",
1.3,
1.3,
"Not set",
1.3,
"Not set",
1.3,
"Not set",
1.3,
"Not set",
1.3,
"Not set",
1.3,
"Not set",
false,
"None",
null,
false,
50,
[],
"",
"",
""
],
"event_data": null,
"fn_index": 232,
"session_hash": "3xedmn4nuzq"
"fn_index": 96,
"session_hash": "kmb0ojjfhdj"
}

View File

@ -16,7 +16,8 @@ import (
type QinNiuOss struct {
config *types.QiNiuOssConfig
token string
mac *qbox.Mac
putPolicy storage.PutPolicy
uploader *storage.FormUploader
manager *storage.BucketManager
proxyURL string
@ -39,7 +40,8 @@ func NewQiNiuOss(appConfig *types.AppConfig) QinNiuOss {
}
return QinNiuOss{
config: config,
token: putPolicy.UploadToken(mac),
mac: mac,
putPolicy: putPolicy,
uploader: formUploader,
manager: storage.NewBucketManager(mac, &storeConfig),
proxyURL: appConfig.ProxyURL,
@ -65,7 +67,7 @@ func (s QinNiuOss) PutFile(ctx *gin.Context, name string) (string, error) {
// 上传文件
ret := storage.PutRet{}
extra := storage.PutExtra{}
err = s.uploader.Put(ctx, &ret, s.token, key, src, file.Size, &extra)
err = s.uploader.Put(ctx, &ret, s.putPolicy.UploadToken(s.mac), key, src, file.Size, &extra)
if err != nil {
return "", err
}
@ -93,7 +95,7 @@ func (s QinNiuOss) PutImg(imageURL string, useProxy bool) (string, error) {
ret := storage.PutRet{}
extra := storage.PutExtra{}
// 上传文件字节数据
err = s.uploader.Put(context.Background(), &ret, s.token, key, bytes.NewReader(imageData), int64(len(imageData)), &extra)
err = s.uploader.Put(context.Background(), &ret, s.putPolicy.UploadToken(s.mac), key, bytes.NewReader(imageData), int64(len(imageData)), &extra)
if err != nil {
return "", err
}

View File

@ -0,0 +1,142 @@
package payment
import (
"chatplus/core/types"
logger2 "chatplus/logger"
"fmt"
"github.com/smartwalle/alipay/v3"
"log"
"net/url"
"os"
)
type AlipayService struct {
config *types.AlipayConfig
client *alipay.Client
}
var logger = logger2.GetLogger()
func NewAlipayService(appConfig *types.AppConfig) (*AlipayService, error) {
config := appConfig.AlipayConfig
if !config.Enabled {
logger.Info("Disabled Alipay service")
return nil, nil
}
priKey, err := readKey(config.PrivateKey)
if err != nil {
return nil, fmt.Errorf("error with read App Private key: %v", err)
}
xClient, err := alipay.New(config.AppId, priKey, !config.SandBox)
if err != nil {
return nil, fmt.Errorf("error with initialize alipay service: %v", err)
}
if err = xClient.LoadAppCertPublicKeyFromFile(config.PublicKey); err != nil {
return nil, fmt.Errorf("error with loading App PublicKey: %v", err)
}
if err = xClient.LoadAliPayRootCertFromFile(config.RootCert); err != nil {
return nil, fmt.Errorf("error with loading alipay RootCert: %v", err)
}
if err = xClient.LoadAlipayCertPublicKeyFromFile(config.AlipayPublicKey); err != nil {
return nil, fmt.Errorf("error with loading Alipay PublicKey: %v", err)
}
return &AlipayService{config: &config, client: xClient}, nil
}
func (s *AlipayService) PayUrlMobile(outTradeNo string, notifyURL string, returnURL string, Amount string, subject string) (string, error) {
var p = alipay.TradeWapPay{}
p.NotifyURL = notifyURL
p.ReturnURL = returnURL
p.Subject = subject
p.OutTradeNo = outTradeNo
p.TotalAmount = Amount
p.ProductCode = "QUICK_WAP_WAY"
res, err := s.client.TradeWapPay(p)
if err != nil {
return "", err
}
return res.String(), err
}
func (s *AlipayService) PayUrlPc(outTradeNo string, notifyURL string, returnURL string, amount string, subject string) (string, error) {
var p = alipay.TradePagePay{}
p.NotifyURL = notifyURL
p.ReturnURL = returnURL
p.Subject = subject
p.OutTradeNo = outTradeNo
p.TotalAmount = amount
p.ProductCode = "FAST_INSTANT_TRADE_PAY"
res, err := s.client.TradePagePay(p)
if err != nil {
return "", nil
}
return res.String(), err
}
// TradeVerify 交易验证
func (s *AlipayService) TradeVerify(reqForm url.Values) NotifyVo {
err := s.client.VerifySign(reqForm)
if err != nil {
log.Println("异步通知验证签名发生错误", err)
return NotifyVo{
Status: 0,
Message: "异步通知验证签名发生错误",
}
}
return s.TradeQuery(reqForm.Get("out_trade_no"))
}
func (s *AlipayService) TradeQuery(outTradeNo string) NotifyVo {
var p = alipay.TradeQuery{}
p.OutTradeNo = outTradeNo
rsp, err := s.client.TradeQuery(p)
if err != nil {
return NotifyVo{
Status: 0,
Message: "异步查询验证订单信息发生错误" + outTradeNo + err.Error(),
}
}
if rsp.IsSuccess() == true && rsp.TradeStatus == "TRADE_SUCCESS" {
return NotifyVo{
Status: 1,
OutTradeNo: rsp.OutTradeNo,
TradeNo: rsp.TradeNo,
Amount: rsp.TotalAmount,
Subject: rsp.Subject,
Message: "OK",
}
} else {
return NotifyVo{
Status: 0,
Message: "异步查询验证订单信息发生错误" + outTradeNo,
}
}
}
func readKey(filename string) (string, error) {
data, err := os.ReadFile(filename)
if err != nil {
return "", err
}
return string(data), nil
}
type NotifyVo struct {
Status int
OutTradeNo string
TradeNo string
Amount string
Message string
Subject string
}
func (v NotifyVo) Success() bool {
return v.Status == 1
}

View File

@ -81,7 +81,7 @@ func (s *Service) Run() {
// PushTask 推送任务到队列
func (s *Service) PushTask(task types.SdTask) {
logger.Infof("add a new MidJourney Task: %+v", task)
logger.Infof("add a new Stable Diffusion Task: %+v", task)
s.taskQueue.RPush(task)
}
@ -105,7 +105,8 @@ func (s *Service) Txt2Img(task types.SdTask) error {
data[ParamKeys["negative_prompt"]] = params.NegativePrompt
data[ParamKeys["steps"]] = params.Steps
data[ParamKeys["sampler"]] = params.Sampler
data[ParamKeys["face_fix"]] = params.FaceFix
// @fix bug: 有些 stable diffusion 没有面部修复功能
//data[ParamKeys["face_fix"]] = params.FaceFix
data[ParamKeys["cfg_scale"]] = params.CfgScale
data[ParamKeys["seed"]] = params.Seed
data[ParamKeys["height"]] = params.Height
@ -176,7 +177,8 @@ func (s *Service) runTask(taskInfo TaskInfo, client *req.Client) {
var info map[string]any
err = utils.JsonDecode(utils.InterfaceToString(res.Data[1]), &info)
if err != nil {
cbReq.Message = err.Error()
logger.Error(res.Data)
cbReq.Message = "error with decode image url:" + err.Error()
cbReq.Success = false
result <- cbReq
return
@ -229,6 +231,7 @@ func (s *Service) runTask(taskInfo TaskInfo, client *req.Client) {
cbReq.ImageData = progressRes.LivePreview
cbReq.Progress = int(progressRes.Progress * 100)
logger.Debug(cbReq)
s.callback(cbReq)
time.Sleep(time.Second)
}

View File

@ -32,14 +32,14 @@ var ParamKeys = map[string]int{
"negative_prompt": 2,
"steps": 4,
"sampler": 5,
"face_fix": 6,
"cfg_scale": 10,
"seed": 11,
"height": 17,
"width": 18,
"hd_fix": 19,
"hd_redraw_rate": 20, //高清修复重绘幅度
"hd_scale": 21, // 高清修复放大倍数
"hd_scale_alg": 22, // 高清修复放大算法
"hd_sample_num": 23, // 高清修复采样次数
"face_fix": 6, // 面部修复
"cfg_scale": 8,
"seed": 27,
"height": 9,
"width": 10,
"hd_fix": 11,
"hd_redraw_rate": 12, //高清修复重绘幅度
"hd_scale": 13, // 高清修复放大倍数
"hd_scale_alg": 14, // 高清修复放大算法
"hd_sample_num": 15, // 高清修复采样次数
}

56
api/service/snowflake.go Normal file
View File

@ -0,0 +1,56 @@
package service
import (
"fmt"
"sync"
"time"
)
// Snowflake 雪花算法实现
type Snowflake struct {
mu sync.Mutex
lastTimestamp int64
workerID int
sequence int
}
func NewSnowflake() *Snowflake {
return &Snowflake{
lastTimestamp: -1,
workerID: 0, // TODO: 增加 WorkID 参数
sequence: 0,
}
}
// Next 生成一个新的唯一ID
func (s *Snowflake) Next() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
timestamp := time.Now().UnixNano() / 1000000 // 转换为毫秒
if timestamp < s.lastTimestamp {
return "", fmt.Errorf("clock moved backwards. Refusing to generate id for %d milliseconds", s.lastTimestamp-timestamp)
}
if timestamp == s.lastTimestamp {
s.sequence = (s.sequence + 1) & 4095
if s.sequence == 0 {
timestamp = s.waitNextMillis()
}
} else {
s.sequence = 0
}
s.lastTimestamp = timestamp
id := (timestamp << 22) | (int64(s.workerID) << 10) | int64(s.sequence)
now := time.Now()
return fmt.Sprintf("%d%02d%02d%d", now.Year(), now.Month(), now.Day(), id), nil
}
func (s *Snowflake) waitNextMillis() int64 {
timestamp := time.Now().UnixNano() / 1000000
for timestamp <= s.lastTimestamp {
timestamp = time.Now().UnixNano() / 1000000
}
return timestamp
}

View File

@ -0,0 +1,144 @@
package service
import (
"chatplus/core/types"
logger2 "chatplus/logger"
"chatplus/store/model"
"chatplus/utils"
"context"
"fmt"
"github.com/xxl-job/xxl-job-executor-go"
"gorm.io/gorm"
"time"
)
var logger = logger2.GetLogger()
type XXLJobExecutor struct {
executor xxl.Executor
db *gorm.DB
}
func NewXXLJobExecutor(config *types.AppConfig, db *gorm.DB) *XXLJobExecutor {
if !config.XXLConfig.Enabled {
logger.Info("XXL-JOB service is disabled")
return nil
}
exec := xxl.NewExecutor(
xxl.ServerAddr(config.XXLConfig.ServerAddr),
xxl.AccessToken(config.XXLConfig.AccessToken), //请求令牌(默认为空)
xxl.ExecutorIp(config.XXLConfig.ExecutorIp), //可自动获取
xxl.ExecutorPort(config.XXLConfig.ExecutorPort), //默认9999非必填
xxl.RegistryKey(config.XXLConfig.RegistryKey), //执行器名称
xxl.SetLogger(&customLogger{}), //自定义日志
)
exec.Init()
return &XXLJobExecutor{executor: exec, db: db}
}
func (e *XXLJobExecutor) Run() error {
e.executor.RegTask("ClearOrder", e.ClearOrder)
e.executor.RegTask("ResetVipCalls", e.ResetVipCalls)
return e.executor.Run()
}
// ClearOrder 清理未支付的订单,如果没有抛出异常则表示执行成功
func (e *XXLJobExecutor) ClearOrder(cxt context.Context, param *xxl.RunReq) (msg string) {
logger.Debug("执行清理未支付订单...")
var sysConfig model.Config
res := e.db.Where("marker", "system").First(&sysConfig)
if res.Error != nil {
return "error with get system config: " + res.Error.Error()
}
var config types.SystemConfig
err := utils.JsonDecode(sysConfig.Config, &config)
if err != nil {
return "error with decode system config: " + err.Error()
}
if config.OrderPayTimeout == 0 { // 默认未支付订单的生命周期为 30 分钟
config.OrderPayTimeout = 1800
}
timeout := time.Now().Unix() - int64(config.OrderPayTimeout)
start := utils.Stamp2str(timeout)
res = e.db.Where("status != ? AND created_at < ?", types.OrderPaidSuccess, start).Delete(&model.Order{})
return fmt.Sprintf("Clear order successfully, affect rows: %d", res.RowsAffected)
}
// ResetVipCalls 清理过期的 VIP 会员
func (e *XXLJobExecutor) ResetVipCalls(cxt context.Context, param *xxl.RunReq) (msg string) {
logger.Info("开始进行月底账号盘点...")
var users []model.User
res := e.db.Where("vip = ?", 1).Find(&users)
if res.Error != nil {
return "No vip users found"
}
var sysConfig model.Config
res = e.db.Where("marker", "system").First(&sysConfig)
if res.Error != nil {
return "error with get system config: " + res.Error.Error()
}
var config types.SystemConfig
err := utils.JsonDecode(sysConfig.Config, &config)
if err != nil {
return "error with decode system config: " + err.Error()
}
// 获取本月月初时间
currentTime := time.Now()
year, month, _ := currentTime.Date()
firstOfMonth := time.Date(year, month, 1, 0, 0, 0, 0, currentTime.Location()).Unix()
for _, u := range users {
// 账号到期,直接清零
if u.ExpiredTime <= currentTime.Unix() {
logger.Info("账号过期:", u.Mobile)
u.Calls = 0
u.Vip = false
} else {
if u.Calls <= 0 {
u.Calls = config.VipMonthCalls
} else {
// 如果该用户当月有充值点卡,则将点卡中未用完的点数结余到下个月
var orders []model.Order
e.db.Debug().Where("user_id = ? AND pay_time > ?", u.Id, firstOfMonth).Find(&orders)
var calls = 0
for _, o := range orders {
var remark types.OrderRemark
err = utils.JsonDecode(o.Remark, &remark)
if err != nil {
continue
}
if remark.Days > 0 { // 会员续费
continue
}
calls += remark.Calls
}
if u.Calls > calls { // 本月套餐没有用完
u.Calls = calls + config.VipMonthCalls
} else {
u.Calls = u.Calls + config.VipMonthCalls
}
logger.Infof("%s 点卡结余:%d", u.Mobile, calls)
}
}
u.Tokens = 0
// update user
e.db.Updates(&u)
}
logger.Info("月底盘点完成!")
return "success"
}
type customLogger struct{}
func (l *customLogger) Info(format string, a ...interface{}) {
logger.Debugf(format, a...)
}
func (l *customLogger) Error(format string, a ...interface{}) {
logger.Errorf(format, a...)
}

22
api/store/model/order.go Normal file
View File

@ -0,0 +1,22 @@
package model
import (
"chatplus/core/types"
"gorm.io/gorm"
)
// Order 充值订单
type Order struct {
BaseModel
UserId uint
ProductId uint
Mobile string
OrderNo string
Subject string
Amount float64
Status types.OrderStatus
Remark string
PayTime int64
PayWay string // 支付方式
DeletedAt gorm.DeletedAt
}

View File

@ -0,0 +1,14 @@
package model
// Product 充值产品
type Product struct {
BaseModel
Name string
Price float64
Discount float64
Days int
Calls int
Enabled bool
Sales int
SortNum int
}

View File

@ -16,4 +16,6 @@ type User struct {
Status bool `gorm:"default:true"` // 当前状态
LastLoginAt int64 // 最后登录时间
LastLoginIp string // 最后登录 IP
Vip bool // 是否 VIP 会员
Tokens int
}

19
api/store/vo/order.go Normal file
View File

@ -0,0 +1,19 @@
package vo
import (
"chatplus/core/types"
)
type Order struct {
BaseVo
UserId uint `json:"user_id"`
ProductId uint `json:"product_id"`
Mobile string `json:"mobile"`
OrderNo string `json:"order_no"`
Subject string `json:"subject"`
Amount float64 `json:"amount"`
Status types.OrderStatus `json:"status"`
PayTime int64 `json:"pay_time"`
PayWay string `json:"pay_way"`
Remark types.OrderRemark `json:"remark"`
}

13
api/store/vo/product.go Normal file
View File

@ -0,0 +1,13 @@
package vo
type Product struct {
BaseVo
Name string `json:"name"`
Price float64 `json:"price"`
Discount float64 `json:"discount"`
Days int `json:"days"`
Calls int `json:"calls"`
Enabled bool `json:"enabled"`
Sales int `json:"sales"`
SortNum int `json:"sort_num"`
}

View File

@ -17,4 +17,6 @@ type User struct {
Status bool `json:"status"` // 当前状态
LastLoginAt int64 `json:"last_login_at"` // 最后登录时间
LastLoginIp string `json:"last_login_ip"` // 最后登录 IP
Vip bool `json:"vip"`
Tokens int `json:"token"` // 当月消耗的 fee
}

View File

@ -1,8 +1,16 @@
package utils
import (
"bytes"
"encoding/json"
"fmt"
"github.com/nfnt/resize"
"github.com/skip2/go-qrcode"
"image"
"image/color"
"image/draw"
"image/jpeg"
"io"
"reflect"
"strconv"
"strings"
@ -140,13 +148,57 @@ func IntValue(str string, defaultValue int) int {
}
func ForceCovert(src any, dst interface{}) error {
bytes, err := json.Marshal(src)
b, err := json.Marshal(src)
if err != nil {
return err
}
err = json.Unmarshal(bytes, dst)
err = json.Unmarshal(b, dst)
if err != nil {
return err
}
return nil
}
func GenQrcode(text string, size int, logo io.Reader) ([]byte, error) {
qr, err := qrcode.New(text, qrcode.Medium)
if err != nil {
return nil, err
}
qr.BackgroundColor = color.White
qr.ForegroundColor = color.Black
if logo == nil {
return qr.PNG(size)
}
// 生成带Logo的二维码图像
logoImage, _, err := image.Decode(logo)
if err != nil {
return nil, err
}
// 缩放 Logo
scaledLogo := resize.Resize(uint(size/9), uint(size/9), logoImage, resize.Lanczos3)
// 将Logo叠加到二维码图像上
qrWithLogo := overlayLogo(qr.Image(size), scaledLogo)
// 将带Logo的二维码图像以JPEG格式编码为图片数据
var buf bytes.Buffer
err = jpeg.Encode(&buf, qrWithLogo, nil)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// 叠加Logo到图片上
func overlayLogo(qrImage, logoImage image.Image) image.Image {
offsetX := (qrImage.Bounds().Dx() - logoImage.Bounds().Dx()) / 2
offsetY := (qrImage.Bounds().Dy() - logoImage.Bounds().Dy()) / 2
combinedImage := image.NewRGBA(qrImage.Bounds())
draw.Draw(combinedImage, qrImage.Bounds(), qrImage, image.Point{}, draw.Over)
draw.Draw(combinedImage, logoImage.Bounds().Add(image.Pt(offsetX, offsetY)), logoImage, image.Point{}, draw.Over)
return combinedImage
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,60 @@
ALTER TABLE `chatgpt_users` ADD `vip` TINYINT(1) NOT NULL DEFAULT '0' COMMENT '是否会员' AFTER `last_login_at`;
ALTER TABLE `chatgpt_users` ADD `tokens` BIGINT NOT NULL DEFAULT '0' COMMENT '当月消耗 tokens' AFTER `total_tokens`;
CREATE TABLE `chatgpt_orders` (
`id` int NOT NULL,
`user_id` int NOT NULL COMMENT '用户ID',
`product_id` int NOT NULL COMMENT '产品ID',
`mobile` char(11) NOT NULL COMMENT '用户手机号',
`order_no` varchar(30) NOT NULL COMMENT '订单ID',
`subject` varchar(100) NOT NULL COMMENT '订单产品',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单金额',
`status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '订单状态0待支付1已扫码2支付失败',
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '备注',
`pay_time` int DEFAULT NULL COMMENT '支付时间',
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
`deleted_at` datetime DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='充值订单表';
--
-- 转存表中的数据 `chatgpt_orders`
--
INSERT INTO `chatgpt_orders` (`id`, `user_id`, `product_id`, `mobile`, `order_no`, `subject`, `amount`, `status`, `remark`, `pay_time`, `created_at`, `updated_at`, `deleted_at`) VALUES
(4, 4, 1, '18575670125', '202308317102915300416290816', '会员1个月', '0.01', 2, '{\"days\":30,\"calls\":500,\"name\":\"会员1个月\",\"discount\":10.99}', 1693466990, '2023-08-31 15:29:33', '2023-08-31 15:29:51', NULL),
(5, 4, 5, '18575670125', '202308317102946758199607296', '100次点卡', '0.30', 2, '{\"days\":0,\"calls\":100,\"name\":\"100次点卡\"}', 1693466990, '2023-08-31 17:34:34', '2023-08-31 17:34:34', NULL),
(6, 4, 5, '18575670125', '202308317102946843595636736', '100次点卡', '0.03', 2, '{\"days\":0,\"calls\":100,\"name\":\"100次点卡\"}', 1693474722, '2023-08-31 17:34:54', '2023-08-31 17:38:43', NULL),
(7, 4, 1, '18575670125', '202309017103252664456052736', '会员1个月', '0.01', 2, '{\"days\":30,\"calls\":0,\"name\":\"会员1个月\"}', 1693466990, '2023-09-01 13:50:07', '2023-09-01 13:50:07', NULL),
(8, 4, 1, '18575670125', '202309017103252894391992320', '会员1个月', '0.01', 2, '{\"days\":30,\"calls\":0,\"name\":\"会员1个月\"}', 1693466990, '2023-09-01 13:51:02', '2023-09-01 13:51:02', NULL),
(9, 4, 5, '18575670125', '202309017103254657538981888', '100次点卡', '0.03', 2, '{\"days\":0,\"calls\":100,\"name\":\"100次点卡\"}', 1693474722, '2023-09-01 13:58:02', '2023-09-01 13:58:02', NULL),
(10, 4, 1, '18575670125', '202309017103259375405367296', '会员1个月', '0.01', 2, '{\"days\":30,\"calls\":0,\"name\":\"会员1个月\"}', 1693474722, '2023-09-01 14:16:47', '2023-09-01 14:16:47', NULL),
(11, 4, 3, '18575670125', '202309017103290730432430080', '会员6个月', '190.00', 2, '{\"days\":180,\"calls\":0,\"name\":\"会员6个月\",\"price\":290,\"discount\":100}', 1693474722, '2023-09-01 16:21:23', '2023-09-01 16:21:23', NULL),
(12, 4, 4, '18575670125', '202309017103291707520712704', '会员12个月', '380.00', 2, '{\"days\":365,\"calls\":0,\"name\":\"会员12个月\",\"price\":580,\"discount\":200}', 1693466990, '2023-09-01 16:25:16', '2023-09-01 16:25:16', NULL);
-- 创建索引
ALTER TABLE `chatgpt_orders` ADD PRIMARY KEY (`id`), ADD UNIQUE KEY `order_no` (`order_no`);
ALTER TABLE `chatgpt_orders` MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=13;
CREATE TABLE `chatgpt_products` (
`id` int NOT NULL,
`name` varchar(30) NOT NULL COMMENT '名称',
`price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '价格',
`discount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '优惠金额',
`days` smallint NOT NULL DEFAULT '0' COMMENT '延长天数',
`calls` int NOT NULL DEFAULT '0' COMMENT '调用次数',
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启动',
`sales` int NOT NULL DEFAULT '0' COMMENT '销量',
`sort_num` tinyint NOT NULL DEFAULT '0' COMMENT '排序',
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会员套餐表';
INSERT INTO `chatgpt_products` (`id`, `name`, `price`, `discount`, `days`, `calls`, `enabled`, `sales`, `sort_num`, `created_at`, `updated_at`) VALUES
(1, '会员1个月', '1.01', '1.00', 30, 0, 1, 0, 0, '2023-08-28 10:48:57', '2023-08-31 16:24:26'),
(2, '会员3个月', '140.00', '30.00', 90, 0, 1, 0, 0, '2023-08-28 10:52:22', '2023-08-31 16:24:31'),
(3, '会员6个月', '290.00', '100.00', 180, 0, 1, 0, 0, '2023-08-28 10:53:39', '2023-08-31 16:24:36'),
(4, '会员12个月', '580.00', '200.00', 365, 0, 1, 0, 0, '2023-08-28 10:54:15', '2023-08-31 16:24:42'),
(5, '100次点卡', '10.03', '10.00', 0, 100, 1, 0, 0, '2023-08-28 10:55:08', '2023-08-31 17:34:43');
ALTER TABLE `chatgpt_products` ADD PRIMARY KEY (`id`);
ALTER TABLE `chatgpt_products` MODIFY `id` int NOT NULL AUTO_INCREMENT, AUTO_INCREMENT=6;

View File

@ -68,3 +68,22 @@ WeChatBot = false
ApiURL = "http://172.22.11.200:7860"
ApiKey = ""
Txt2ImgJsonPath = "res/text2img.json"
[XXLConfig] # xxl-job 配置,需要你部署 XXL-JOB 定时任务工具,用来定期清理未支付订单和清理过期 VIP如果你没有启用支付服务则该服务也无需启动
Enabled = false # 是否启用 XXL JOB 服务
ServerAddr = "http://172.22.11.47:8080/xxl-job-admin" # xxl-job-admin 管理地址
ExecutorIp = "172.22.11.47" # 执行器 IP 地址
ExecutorPort = "9999" # 执行器服务端口
AccessToken = "xxl-job-api-token" # 执行器 API 通信 token
RegistryKey = "chatgpt-plus" # 任务注册 key
[AlipayConfig]
Enabled = false # 启用支付宝支付通道
SandBox = false # 是否启用沙盒模式
UserId = "2088721020750581" # 商户ID
AppId = "9021000131658023" # App Id
PrivateKey = "certs/alipay/privateKey.txt" # 应用私钥
PublicKey = "certs/alipay/appPublicCert.crt" # 应用公钥证书
AlipayPublicKey = "certs/alipay/alipayPublicCert.crt" # 支付宝公钥证书
RootCert = "certs/alipay/alipayRootCert.crt" # 支付宝根证书
NotifyURL = "http://r9it.com:6004/api/payment/alipay/notify" # 支付异步回调地址

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
web/public/images/vip.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -16,8 +16,14 @@
<el-input v-model="form.mobile"/>
</el-form-item>
<el-form-item label="手机验证码">
<el-input v-model.number="form.code" maxlength="6" style="max-width: 200px; margin-right: 10px;"/>
<el-row :gutter="20">
<el-col :span="16">
<el-input v-model.number="form.code" maxlength="6"/>
</el-col>
<el-col :span="8">
<send-msg size="" :mobile="form.mobile"/>
</el-col>
</el-row>
</el-form-item>
</el-form>
</div>
@ -81,6 +87,12 @@ const close = function () {
}
</script>
<style scoped>
<style lang="stylus" scoped>
#bind-mobile-form {
.el-form-item__content {
.el-row {
width 100%
}
}
}
</style>

View File

@ -125,6 +125,31 @@ export default defineComponent({
p:first-child {
margin-top 0
}
//
table {
width 100%
margin-bottom 1rem
color #212529
border-collapse collapse;
border 1px solid #dee2e6;
background-color #ffffff
thead {
th {
border 1px solid #dee2e6
vertical-align: bottom
border-bottom: 2px solid #dee2e6
padding 10px
}
}
td {
border 1px solid #dee2e6
padding 10px
}
}
}

View File

@ -5,55 +5,30 @@
:close-on-click-modal="true"
:before-close="close"
style="max-width: 600px"
title="用户设置"
title="账户信息"
>
<div class="user-info" id="user-info">
<el-form v-if="form.id" :model="form" label-width="150px">
<el-form v-if="user.id" :model="user" label-width="150px">
<el-form-item label="账户">
<span>{{ form.mobile }}</span>
</el-form-item>
<el-form-item label="头像">
<el-upload
class="avatar-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="afterRead"
>
<el-avatar v-if="form.avatar" :src="form.avatar" shape="square" :size="100"/>
<el-icon v-else class="avatar-uploader-icon">
<Plus/>
</el-icon>
</el-upload>
<span>{{ user.mobile }}</span>
</el-form-item>
<el-form-item label="剩余对话次数">
<el-tag>{{ form['calls'] }}</el-tag>
<el-tag>{{ user['calls'] }}</el-tag>
</el-form-item>
<el-form-item label="剩余绘图次数">
<el-tag>{{ form['img_calls'] }}</el-tag>
<el-tag>{{ user['img_calls'] }}</el-tag>
</el-form-item>
<el-form-item label="累计消耗 Tokens">
<el-tag type="info">{{ form['total_tokens'] }}</el-tag>
<el-form-item label="本月消耗电量">
<el-tag type="info">{{ user['tokens'] }}</el-tag>
</el-form-item>
<el-form-item label="OpenAI API KEY">
<el-input v-model="form.chat_config['api_keys']['OpenAI']"/>
<el-form-item label="累计消耗电量">
<el-tag type="info">{{ user['total_tokens'] }}</el-tag>
</el-form-item>
<el-form-item label="Azure API KEY">
<el-input v-model="form['chat_config']['api_keys']['Azure']"/>
</el-form-item>
<el-form-item label="ChatGLM API KEY">
<el-input v-model="form['chat_config']['api_keys']['ChatGLM']"/>
<el-form-item label="会员到期时间" v-if="user['expired_time'] > 0">
<el-tag type="danger">{{ dateFormat(user['expired_time']) }}</el-tag>
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="close">关闭</el-button>
<el-button type="primary" @click="save">
保存
</el-button>
</span>
</template>
</el-dialog>
</template>
@ -63,6 +38,7 @@ import {httpGet, httpPost} from "@/utils/http";
import {ElMessage} from "element-plus";
import {Plus} from "@element-plus/icons-vue";
import Compressor from "compressorjs";
import {dateFormat} from "@/utils/libs";
// eslint-disable-next-line no-undef
const props = defineProps({
@ -74,7 +50,7 @@ const props = defineProps({
const showDialog = computed(() => {
return props.show
})
const form = ref({
const user = ref({
username: '',
nickname: '',
avatar: '',
@ -87,50 +63,15 @@ const form = ref({
onMounted(() => {
//
httpGet('/api/user/profile').then(res => {
form.value = res.data
form.value.chat_config.api_keys = res.data.chat_config.api_keys ?? {OpenAI: "", Azure: "", ChatGLM: ""}
user.value = res.data
user.value.chat_config.api_keys = res.data.chat_config.api_keys ?? {OpenAI: "", Azure: "", ChatGLM: ""}
}).catch(e => {
ElMessage.error("获取用户信息失败:" + e.message)
});
})
const afterRead = (file) => {
// console.log(file)
//
new Compressor(file.file, {
quality: 0.6,
success(result) {
const formData = new FormData();
formData.append('file', result, result.name);
//
httpPost('/api/upload', formData).then((res) => {
form.value.avatar = res.data
ElMessage.success({message: "上传成功", duration: 500})
}).catch((e) => {
ElMessage.error('上传失败:' + e.message)
})
},
error(err) {
console.log(err.message);
},
});
};
// eslint-disable-next-line no-undef
const emits = defineEmits(['hide', 'update-user']);
const save = function () {
httpPost('/api/user/profile/update', form.value).then(() => {
ElMessage.success({
message: '更新成功',
duration: 500,
onClose: () => emits('hide', false)
})
//
emits('update-user', {nickname: form.value['nickname'], avatar: form.value['avatar']});
}).catch((e) => {
ElMessage.error('更新失败:' + e.message)
})
}
const emits = defineEmits(['hide']);
const close = function () {
emits('hide', false);
}

View File

@ -0,0 +1,97 @@
<template>
<!-- 倒计时组件 -->
<div class="countdown">
<el-tag size="large" :type="type">{{ timerStr }}</el-tag>
</div>
</template>
<script setup>
import {onMounted, ref, watch} from "vue";
const props = defineProps({
second: Number,
type: {
type: String,
default: ""
}
});
const emits = defineEmits(['timeout']);
const counter = ref(props.second)
const timerStr = ref("")
const handler = ref(null)
watch(() => props.second, (newVal) => {
counter.value = newVal
resetTimer()
});
onMounted(() => {
resetTimer()
})
const resetTimer = () => {
if (handler.value) {
clearInterval(handler.value)
}
counter.value = props.second
formatTimer(counter.value)
handler.value = setInterval(() => {
formatTimer(counter.value)
if (counter.value === 0) {
clearInterval(handler.value)
emits("timeout")
}
counter.value--
}, 1000)
}
const formatTimer = (secs) => {
const timer = []
let hour, min
//
if (secs > 3600) {
hour = Math.floor(secs / 3600)
if (hour < 10) {
hour = "0" + hour
}
secs = secs % 3600
timer.push(hour + " 时 ")
} else {
timer.push("00 时 ")
}
//
if (secs > 60) {
min = Math.floor(secs / 60)
if (min < 10) {
min = "0" + min
}
secs = secs % 60
timer.push(min + " 分 ")
} else {
timer.push("00 分 ")
}
//
if (secs < 10) {
secs = "0" + secs
}
timer.push(secs + " 秒")
timerStr.value = timer.join("")
}
defineExpose({resetTimer})
</script>
<style lang="stylus">
.countdown {
display flex
.el-tag--large {
.el-tag__content {
font-size 14px
}
}
}
</style>

View File

@ -9,7 +9,7 @@
title="用户登录"
>
<div class="form">
<el-form label-width="65px">
<el-form label-width="75px">
<el-form-item>
<template #label>
<div class="label">
@ -20,7 +20,7 @@
</div>
</template>
<template #default>
<el-input v-model="username" placeholder="手机号码"/>
<el-input v-model="username" size="large" placeholder="手机号码"/>
</template>
</el-form-item>
<el-form-item>
@ -33,12 +33,12 @@
</div>
</template>
<template #default>
<el-input v-model="password" type="password" placeholder="密码"/>
<el-input v-model="password" type="password" size="large" placeholder="密码"/>
</template>
</el-form-item>
<div class="login-btn">
<el-button type="primary" @click="submit" round>登录</el-button>
<el-button type="primary" @click="submit" size="large" round>登录</el-button>
</div>
</el-form>
</div>
@ -90,9 +90,17 @@ const close = function () {
border-radius 20px
.label {
padding-top 3px
.el-icon {
font-size 16px
position relative
font-size 20px
margin-right 6px
top 4px
}
span {
font-size 16px
}
}

View File

@ -0,0 +1,143 @@
<template>
<div class="user-info" id="user-info">
<el-form v-if="user.id" :model="user" label-width="150px">
<el-row>
<el-upload
class="avatar-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="afterRead"
>
<el-avatar v-if="user.avatar" :src="user.avatar" shape="circle" :size="100"/>
<el-icon v-else class="avatar-uploader-icon">
<Plus/>
</el-icon>
</el-upload>
</el-row>
<el-form-item label="账户">
<span>{{ user.mobile }}</span>
<el-tooltip
class="box-item"
effect="light"
content="您已经是 VIP 会员"
placement="right"
>
<el-image v-if="user.vip" :src="vipImg" style="height: 25px;margin-left: 10px"/>
</el-tooltip>
</el-form-item>
<el-form-item label="剩余对话次数">
<el-tag>{{ user['calls'] }}</el-tag>
</el-form-item>
<el-form-item label="剩余绘图次数">
<el-tag>{{ user['img_calls'] }}</el-tag>
</el-form-item>
<el-form-item label="本月消耗电量">
<el-tag type="info">{{ user['tokens'] }}</el-tag>
</el-form-item>
<el-form-item label="累计消耗电量">
<el-tag type="info">{{ user['total_tokens'] }}</el-tag>
</el-form-item>
<el-form-item label="会员到期时间" v-if="user['expired_time'] > 0">
<el-tag type="danger">{{ dateFormat(user['expired_time']) }}</el-tag>
</el-form-item>
<el-form-item label="OpenAI API KEY">
<el-input v-model="user.chat_config['api_keys']['OpenAI']"/>
</el-form-item>
<el-form-item label="Azure API KEY">
<el-input v-model="user['chat_config']['api_keys']['Azure']"/>
</el-form-item>
<el-form-item label="ChatGLM API KEY">
<el-input v-model="user['chat_config']['api_keys']['ChatGLM']"/>
</el-form-item>
<el-row class="opt-line">
<el-button color="#47fff1" :dark="false" round @click="save">保存</el-button>
</el-row>
</el-form>
</div>
</template>
<script setup>
import {onMounted, ref} from "vue"
import {httpGet, httpPost} from "@/utils/http";
import {ElMessage} from "element-plus";
import {Plus} from "@element-plus/icons-vue";
import Compressor from "compressorjs";
import {dateFormat} from "@/utils/libs";
import {checkSession} from "@/action/session";
const user = ref({
vip: false,
username: '',
nickname: '',
avatar: '',
mobile: '',
calls: 0,
tokens: 0,
chat_config: {api_keys: {OpenAI: "", Azure: "", ChatGLM: ""}}
})
const vipImg = ref("/images/vip.png")
onMounted(() => {
checkSession().then(() => {
//
httpGet('/api/user/profile').then(res => {
user.value = res.data
user.value.chat_config.api_keys = res.data.chat_config.api_keys ?? {OpenAI: "", Azure: "", ChatGLM: ""}
}).catch(e => {
ElMessage.error("获取用户信息失败:" + e.message)
});
}).catch(e => {
console.log(e)
})
})
const afterRead = (file) => {
//
new Compressor(file.file, {
quality: 0.6,
success(result) {
const formData = new FormData();
formData.append('file', result, result.name);
//
httpPost('/api/upload', formData).then((res) => {
user.value.avatar = res.data
ElMessage.success({message: "上传成功", duration: 500})
}).catch((e) => {
ElMessage.error('图片上传失败:' + e.message)
})
},
error(err) {
console.log(err.message);
},
});
};
const save = () => {
httpPost('/api/user/profile/update', user.value).then(() => {
ElMessage.success({message: '更新成功', duration: 500})
}).catch((e) => {
ElMessage.error('更新失败:' + e.message)
})
}
</script>
<style lang="stylus" scoped>
.user-info {
padding 20px
.el-row {
justify-content center
margin-bottom 10px
}
.opt-line {
padding-top 20px
.el-button {
width 100%
}
}
}
</style>

View File

@ -96,6 +96,16 @@ const items = [
index: '/admin/chat/model',
title: '语言模型',
},
{
icon: 'recharge',
index: '/admin/product',
title: '充值产品',
},
{
icon: 'order',
index: '/admin/order',
title: '充值订单',
},
{
icon: 'reward',
index: '/admin/reward',

View File

@ -32,7 +32,7 @@ import {
TextEllipsis,
Uploader
} from "vant";
import router from "@/router";
import {router} from "@/router";
import 'v3-waterfall/dist/style.css'
import V3waterfall from "v3-waterfall";

View File

@ -126,6 +126,18 @@ const routes = [
meta: {title: '语言模型'},
component: () => import('@/views/admin/ChatModel.vue'),
},
{
path: '/admin/product',
name: 'admin-product',
meta: {title: '充值产品'},
component: () => import('@/views/admin/Product.vue'),
},
{
path: '/admin/order',
name: 'admin-order',
meta: {title: '充值订单'},
component: () => import('@/views/admin/Order.vue'),
},
{
path: '/admin/reward',
name: 'admin-reward',
@ -214,12 +226,14 @@ const router = createRouter({
routes: routes,
})
let prevRoute = null
// dynamic change the title when router change
router.beforeEach((to, from, next) => {
if (to.meta.title) {
document.title = `${to.meta.title} | ${process.env.VUE_APP_TITLE}`
}
prevRoute = from
next()
})
export default router;
export {router, prevRoute};

View File

@ -40,7 +40,7 @@
</template>
<script setup>
import {nextTick, onMounted, ref} from "vue"
import {onMounted, ref} from "vue"
import {ElMessage} from "element-plus";
import {httpGet, httpPost} from "@/utils/http";
import ItemList from "@/components/ItemList.vue";
@ -48,7 +48,6 @@ import {Delete, Plus} from "@element-plus/icons-vue";
import LoginDialog from "@/components/LoginDialog.vue";
import {checkSession} from "@/action/session";
import {arrayContains, removeArrayItem, substr} from "@/utils/libs";
import router from "@/router";
const listBoxHeight = window.innerHeight - 97
const list = ref([])
@ -71,8 +70,8 @@ onMounted(() => {
})
const getRoles = () => {
checkSession().then(user => {
showLoginDialog.value = false
checkSession().then(user => {
roles.value = user.chat_roles
}).catch(() => {
})

View File

@ -52,33 +52,7 @@
<el-icon>
<Tools/>
</el-icon>
<span>聊天设置</span>
</el-dropdown-item>
<el-dropdown-item @click="showPasswordDialog=true">
<i class="iconfont icon-password"></i>
<span>修改密码</span>
</el-dropdown-item>
<el-dropdown-item @click="showBindMobileDialog = true">
<el-icon>
<Iphone/>
</el-icon>
<span>绑定手机号</span>
</el-dropdown-item>
<el-dropdown-item @click="showRewardDialog = true" v-if="enableReward">
<el-icon>
<Present/>
</el-icon>
<span>加入众筹</span>
</el-dropdown-item>
<el-dropdown-item @click="showRewardVerifyDialog = true" v-if="enableReward">
<el-icon>
<Checked/>
</el-icon>
<span>众筹核销</span>
<span>账户信息</span>
</el-dropdown-item>
<el-dropdown-item @click="clearAllChats">
@ -220,32 +194,7 @@
</el-main>
</el-container>
<config-dialog v-if="isLogin" :show="showConfigDialog" :models="models" @hide="showConfigDialog = false"
@update-user="updateUser"/>
<password-dialog v-if="isLogin" :show="showPasswordDialog" @hide="showPasswordDialog = false"
@logout="logout"/>
<bind-mobile v-if="isLogin" :show="showBindMobileDialog" :mobile="loginUser.mobile"
@hide="showBindMobileDialog = false"/>
<reward-verify v-if="isLogin" :show="showRewardVerifyDialog" @hide="showRewardVerifyDialog = false"/>
<el-dialog
v-model="showRewardDialog"
:show-close="true"
width="400px"
title="参与众筹"
>
<el-alert type="info" :closable="false">
<div style="font-size: 14px">您好众筹 9.9就可以兑换 100 次对话以此来覆盖我们的 OpenAI
账单和服务器的费用<strong
style="color: #f56c6c">由于本人没有开通微信支付付款后请凭借转账单号进入核销众筹核销菜单手动核销</strong>
</div>
</el-alert>
<div style="text-align: center;padding-top: 10px;">
<el-image v-if="enableReward" :src="rewardImg"/>
</div>
</el-dialog>
<config-dialog v-if="isLogin" :show="showConfigDialog" :models="models" @hide="showConfigDialog = false"/>
</div>
@ -257,13 +206,10 @@ import ChatReply from "@/components/ChatReply.vue";
import {
ArrowDown,
Check,
Checked,
Close,
Delete,
Edit,
Iphone,
Plus,
Present,
Promotion,
RefreshRight,
Search,
@ -279,16 +225,11 @@ import {httpGet, httpPost} from "@/utils/http";
import {useRouter} from "vue-router";
import Clipboard from "clipboard";
import ConfigDialog from "@/components/ConfigDialog.vue";
import PasswordDialog from "@/components/PasswordDialog.vue";
import {checkSession} from "@/action/session";
import BindMobile from "@/components/BindMobile.vue";
import RewardVerify from "@/components/RewardVerify.vue";
import Welcome from "@/components/Welcome.vue";
import ChatMidJourney from "@/components/ChatMidJourney.vue";
const title = ref('ChatGPT-智能助手');
const enableReward = ref(false) //
const rewardImg = ref('/images/reward.png')
const models = ref([])
const modelID = ref(0)
const chatData = ref([]);
@ -305,10 +246,6 @@ const roleId = ref(0)
const newChatItem = ref(null);
const router = useRouter();
const showConfigDialog = ref(false);
const showPasswordDialog = ref(false);
const showBindMobileDialog = ref(false);
const showRewardDialog = ref(false);
const showRewardVerifyDialog = ref(false);
const isLogin = ref(false)
const showHello = ref(true)
const textInput = ref(null)
@ -360,8 +297,6 @@ onMounted(() => {
httpGet("/api/admin/config/get?key=system").then(res => {
title.value = res.data.title
rewardImg.value = res.data.reward_img
enableReward.value = res.data.enabled_reward
}).catch(e => {
ElMessage.error("获取系统配置失败:" + e.message)
})
@ -855,11 +790,6 @@ const searchChat = function () {
chatList.value = items;
}
const updateUser = function (data) {
loginUser.value.avatar = data.avatar;
loginUser.value.nickname = data.nickname;
}
//
const exportChat = () => {
if (!activeChat.value['chat_id']) {

View File

@ -58,6 +58,7 @@ import {isMobile} from "@/utils/libs";
import {checkSession} from "@/action/session";
import {setUserToken} from "@/store/session";
import {validateMobile} from "@/utils/validate";
import {prevRoute} from "@/router";
const router = useRouter();
const title = ref('ChatGPT-PLUS 用户登录');
@ -91,11 +92,16 @@ const login = function () {
httpPost('/api/user/login', {username: username.value.trim(), password: password.value.trim()}).then((res) => {
setUserToken(res.data)
if (prevRoute.path === '') {
if (isMobile()) {
router.push('/mobile')
} else {
router.push('/chat')
}
} else {
router.push(prevRoute.path)
}
}).catch((e) => {
ElMessage.error('登录失败,' + e.message)
})

View File

@ -1,39 +1,417 @@
<template>
<div class="page-member" :style="{ height: winHeight + 'px' }">
<div class="inner">
<h1>会员充值中心</h1>
<h2>页面正在紧锣密鼓开发中敬请期待</h2>
<div class="member custom-scroll">
<div class="title">
会员充值中心
</div>
<div class="inner" :style="{height: listBoxHeight + 'px'}">
<div class="user-profile">
<user-profile/>
<el-row class="user-opt" :gutter="20">
<el-col :span="12">
<el-button type="primary" @click="showPasswordDialog = true">修改密码</el-button>
</el-col>
<el-col :span="12">
<el-button type="primary" @click="showBindMobileDialog = true">绑定手机号</el-button>
</el-col>
<el-col :span="12">
<el-button type="primary" v-if="enableReward" @click="showRewardDialog = true">加入众筹</el-button>
</el-col>
<el-col :span="12">
<el-button type="primary" v-if="enableReward" @click="showRewardVerifyDialog = true">众筹核销</el-button>
</el-col>
<el-col :span="24" style="padding-top: 30px">
<el-button type="danger" round @click="logout">退出登录</el-button>
</el-col>
</el-row>
</div>
<div class="product-box">
<div class="info">
<el-alert type="info" show-icon :closable="false" effect="dark">
<strong>说明:</strong> 成为本站会员后每月有500次对话额度50 AI 绘画额度限制下月1号解除若在期间超过次数后可单独购买点卡
当月充值的点卡有效期可以延期到下个月底
</el-alert>
</div>
<ItemList :items="list" v-if="list.length > 0" :gap="30" :width="200">
<template #default="scope">
<div class="product-item" :style="{width: scope.width+'px'}" @click="orderPay(scope.item)">
<div class="image-container">
<el-image :src="vipImg" fit="cover"/>
</div>
<div class="product-title">
<span class="name">{{ scope.item.name }}</span>
</div>
<div class="product-info">
<div class="info-line">
<span class="label">商品原价</span>
<span class="price">{{ scope.item.price }}</span>
</div>
<div class="info-line">
<span class="label">促销立减</span>
<span class="price">{{ scope.item.discount }}</span>
</div>
<div class="info-line">
<span class="label">有效期</span>
<span class="expire" v-if="scope.item.days > 0">{{ scope.item.days }}</span>
<span class="expire" v-else>当月有效</span>
</div>
</div>
</div>
</template>
</ItemList>
</div>
</div>
<login-dialog :show="showLoginDialog" @hide="showLoginDialog = false"/>
<password-dialog v-if="isLogin" :show="showPasswordDialog" @hide="showPasswordDialog = false"
@logout="logout"/>
<bind-mobile v-if="isLogin" :show="showBindMobileDialog" :mobile="user.mobile"
@hide="showBindMobileDialog = false"/>
<reward-verify v-if="isLogin" :show="showRewardVerifyDialog" @hide="showRewardVerifyDialog = false"/>
<el-dialog
v-model="showRewardDialog"
:show-close="true"
width="400px"
title="参与众筹"
>
<el-alert type="info" :closable="false">
<div style="font-size: 14px">您好众筹 9.9就可以兑换 100 次对话以此来覆盖我们的 OpenAI
账单和服务器的费用<strong
style="color: #f56c6c">由于本人没有开通微信支付付款后请凭借转账单号,点击众筹核销按钮手动核销</strong>
</div>
</el-alert>
<div style="text-align: center;padding-top: 10px;">
<el-image v-if="enableReward" :src="rewardImg"/>
</div>
</el-dialog>
<el-dialog
v-model="showPayDialog"
:close-on-click-modal="false"
:show-close="true"
:width="400"
title="充值订单支付">
<div class="pay-container">
<div class="count-down">
<count-down :second="orderTimeout" @timeout="orderPay" ref="countDown"/>
</div>
<div class="pay-qrcode" v-loading="loading">
<el-image :src="qrcode"/>
</div>
<div class="tip success" v-if="text !== ''">
<el-icon>
<SuccessFilled/>
</el-icon>
<span class="text">{{ text }}</span>
</div>
<div class="tip" v-else>
<el-icon>
<InfoFilled/>
</el-icon>
<span class="text">请打开手机支付宝扫码支付</span>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import {ref} from "vue"
import {onMounted, ref} from "vue"
import {ElMessage} from "element-plus";
import {httpGet, httpPost} from "@/utils/http";
import ItemList from "@/components/ItemList.vue";
import {InfoFilled, SuccessFilled} from "@element-plus/icons-vue";
import LoginDialog from "@/components/LoginDialog.vue";
import {checkSession} from "@/action/session";
import UserProfile from "@/components/UserProfile.vue";
import PasswordDialog from "@/components/PasswordDialog.vue";
import BindMobile from "@/components/BindMobile.vue";
import RewardVerify from "@/components/RewardVerify.vue";
import {useRouter} from "vue-router";
import {removeUserToken} from "@/store/session";
import CountDown from "@/components/CountDown.vue";
const winHeight = ref(window.innerHeight)
</script>
const listBoxHeight = window.innerHeight - 97
const list = ref([])
const showLoginDialog = ref(false)
const showPayDialog = ref(false)
const elements = ref(null)
const vipImg = ref("/images/vip.png")
const enableReward = ref(false) //
const rewardImg = ref('/images/reward.png')
const qrcode = ref("")
const showPasswordDialog = ref(false);
const showBindMobileDialog = ref(false);
const showRewardDialog = ref(false);
const showRewardVerifyDialog = ref(false);
const text = ref("")
const user = ref(null)
const isLogin = ref(false)
const router = useRouter()
const curPayProduct = ref(null)
const activeOrderNo = ref("")
const countDown = ref(null)
const orderTimeout = ref(1800)
const loading = ref(true)
<style lang="stylus" scoped>
.page-member {
display: flex;
justify-content: center;
align-items center
background-color: #282c34;
.inner {
text-align center
onMounted(() => {
checkSession().then(_user => {
user.value = _user
isLogin.value = true
httpGet("/api/product/list").then((res) => {
list.value = res.data
}).catch(e => {
ElMessage.error("获取产品套餐失败:" + e.message)
})
}).catch(() => {
router.push("/login")
})
h1 {
color: #202020;
font-size: 80px;
font-weight: bold;
letter-spacing: 0.1em;
text-shadow: -1px -1px 1px #111111, 2px 2px 1px #363636;
httpGet("/api/admin/config/get?key=system").then(res => {
rewardImg.value = res.data['reward_img']
enableReward.value = res.data['enabled_reward']
if (res.data['order_pay_timeout'] > 0) {
orderTimeout.value = res.data['order_pay_timeout']
}
}).catch(e => {
ElMessage.error("获取系统配置失败:" + e.message)
})
})
const orderPay = (row) => {
if (!user.value.id) {
showLoginDialog.value = true
return
}
if (row) {
curPayProduct.value = row
}
loading.value = true
httpPost("/api/payment/alipay/qrcode", {product_id: curPayProduct.value.id, user_id: user.value.id}).then(res => {
showPayDialog.value = true
qrcode.value = res.data['image']
activeOrderNo.value = res.data['order_no']
queryOrder(activeOrderNo.value)
loading.value = false
//
if (countDown.value) {
countDown.value.resetTimer()
}
}).catch(e => {
ElMessage.error("生成支付订单失败:" + e.message)
})
}
h2 {
color #ffffff;
font-weight: bold;
const queryOrder = (orderNo) => {
httpPost("/api/payment/query", {order_no: orderNo}).then(res => {
if (res.data.status === 1) {
text.value = "扫码成功,请在手机上进行支付!"
queryOrder(orderNo)
} else if (res.data.status === 2) {
text.value = "支付成功,正在刷新页面"
setTimeout(() => location.reload(), 500)
} else {
//
if (activeOrderNo.value === orderNo) {
queryOrder(orderNo)
}
}
}).catch(e => {
ElMessage.error("查询支付状态失败:" + e.message)
})
}
const logout = function () {
httpGet('/api/user/logout').then(() => {
removeUserToken();
router.push('/login');
}).catch(() => {
ElMessage.error('注销失败!');
})
}
</script>
<style lang="stylus">
@import "@/assets/css/custom-scroll.styl"
.member {
background-color: #282c34;
height 100vh
.el-dialog {
.el-dialog__body {
padding-top 10px
.pay-container {
.count-down {
display flex
justify-content center
}
.pay-qrcode {
display flex
justify-content center
.el-image {
width 360px;
height 360px;
}
}
.tip {
display flex
justify-content center
.el-icon {
font-size 24px
}
.text {
font-size: 16px
margin-left 10px
}
}
.tip.success {
color #07c160
}
}
}
}
.title {
text-align center
background-color #25272d
font-size 24px
color #ffffff
padding 10px
border-bottom 1px solid #3c3c3c
}
.inner {
display flex
color #ffffff
padding 15px;
overflow-y visible
overflow-x hidden
.user-profile {
padding 10px 20px
background-color #393F4A
color #ffffff
border-radius 10px
.el-form-item__label {
color #ffffff
justify-content start
}
.user-opt {
.el-col {
padding 10px
.el-button {
width 100%
}
}
}
}
.product-box {
padding 0 10px
.info {
.el-alert__description {
font-size 14px !important
margin 0
}
padding 10px 20px
}
.list-box {
.product-item {
border 1px solid #666666
border-radius 6px
overflow hidden
cursor pointer
transition: all 0.3s ease; /* 添加过渡效果 */
.image-container {
display flex
justify-content center
.el-image {
padding 6px
.el-image__inner {
border-radius 10px
}
}
}
.product-title {
display flex
padding 10px
.name {
width 100%
text-align center
font-size 16px
font-weight bold
color #47fff1
}
}
.product-info {
padding 10px 20px
font-size 14px
color #999999
.info-line {
display flex
width 100%
padding 5px 0
.label {
display flex
width 100%
}
.price, .expire {
display flex
width 90px
justify-content right
}
.price {
color #f56c6c
}
.expire {
color #409eff
}
}
}
&:hover {
box-shadow: 0 0 10px rgba(71, 255, 241, 0.6); /* 添加阴影效果 */
transform: translateY(-10px); /* 向上移动10像素 */
}
}
}
}
}

View File

@ -0,0 +1,140 @@
<template>
<div class="container order" v-loading="loading">
<div class="handle-box">
<el-input v-model="query.order_no" placeholder="订单号" class="handle-input mr10"></el-input>
<el-date-picker
v-model="query.pay_time"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="margin-right: 10px;width: 200px; position: relative;top:3px;"
/>
<el-button type="primary" :icon="Search" @click="fetchData">搜索</el-button>
</div>
<el-row>
<el-table :data="items" :row-key="row => row.id" table-layout="auto">
<el-table-column prop="order_no" label="订单号"/>
<el-table-column prop="mobile" label="下单用户"/>
<el-table-column prop="subject" label="产品名称"/>
<el-table-column prop="amount" label="订单金额"/>
<el-table-column label="调用次数">
<template #default="scope">
<span>{{ scope.row.remark?.calls }}</span>
</template>
</el-table-column>
<el-table-column label="下单时间">
<template #default="scope">
<span>{{ dateFormat(scope.row['created_at']) }}</span>
</template>
</el-table-column>
<el-table-column label="支付时间">
<template #default="scope">
<span v-if="scope.row['pay_time']">{{ dateFormat(scope.row['pay_time']) }}</span>
<el-tag v-else>未支付</el-tag>
</template>
</el-table-column>
<el-table-column prop="pay_way" label="支付方式"/>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-popconfirm title="确定要删除当前记录吗?" @confirm="remove(scope.row)">
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-row>
<div class="pagination">
<el-pagination v-if="total > 0" background
layout="total,prev, pager, next"
:hide-on-single-page="true"
v-model:current-page="page"
v-model:page-size="pageSize"
@current-change="fetchData()"
:total="total"/>
</div>
</div>
</template>
<script setup>
import {onMounted, ref} from "vue";
import {httpGet, httpPost} from "@/utils/http";
import {ElMessage} from "element-plus";
import {dateFormat, removeArrayItem} from "@/utils/libs";
import {Search} from "@element-plus/icons-vue";
//
const items = ref([])
const query = ref({order_no: "", pay_time: []})
const total = ref(0)
const page = ref(1)
const pageSize = ref(15)
const loading = ref(true)
onMounted(() => {
fetchData()
})
//
const fetchData = () => {
query.value.page = page.value
query.value.page_size = pageSize.value
httpPost('/api/admin/order/list', query.value).then((res) => {
if (res.data) {
items.value = res.data.items
total.value = res.data.total
page.value = res.data.page
pageSize.value = res.data.page_size
}
loading.value = false
}).catch(e => {
ElMessage.error("获取数据失败:" + e.message);
})
}
const remove = function (row) {
httpGet('/api/admin/order/remove?id=' + row.id).then(() => {
ElMessage.success("删除成功!")
items.value = removeArrayItem(items.value, row, (v1, v2) => {
return v1.id === v2.id
})
}).catch((e) => {
ElMessage.error("删除失败:" + e.message)
})
}
</script>
<style lang="stylus" scoped>
.order {
.handle-box {
.handle-input {
max-width 150px;
margin-right 10px;
}
}
.opt-box {
padding-bottom: 10px;
display flex;
justify-content flex-end
.el-icon {
margin-right: 5px;
}
}
.el-select {
width: 100%
}
}
</style>

View File

@ -0,0 +1,226 @@
<template>
<div class="container list" v-loading="loading">
<div class="handle-box">
<el-button type="primary" :icon="Plus" @click="add">新增</el-button>
</div>
<el-row>
<el-table :data="items" :row-key="row => row.id" table-layout="auto">
<el-table-column prop="name" label="产品名称">
<template #default="scope">
<span class="sort" :data-id="scope.row.id">{{scope.row.name}}</span>
</template>
</el-table-column>
<el-table-column prop="price" label="产品价格"/>
<el-table-column prop="discount" label="优惠金额"/>
<el-table-column prop="days" label="有效期(天)">
<template #default="scope">
<el-tag v-if="scope.row.days === 0">长期有效</el-tag>
<span v-else>{{scope.row.days}}</span>
</template>
</el-table-column>
<el-table-column prop="calls" label="调用次数"/>
<el-table-column prop="sales" label="销量"/>
<el-table-column prop="enabled" label="启用状态">
<template #default="scope">
<el-switch v-model="scope.row['enabled']" @change="enable(scope.row)"/>
</template>
</el-table-column>
<el-table-column label="更新时间">
<template #default="scope">
<span>{{ dateFormat(scope.row['updated_at']) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="scope">
<el-button size="small" type="primary" @click="edit(scope.row)">编辑</el-button>
<el-popconfirm title="确定要删除当前记录吗?" @confirm="remove(scope.row)">
<template #reference>
<el-button size="small" type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-row>
<el-dialog
v-model="showDialog"
:title="title"
style="width: 90%; max-width: 600px;"
>
<el-form :model="item" label-width="120px" ref="formRef" :rules="rules">
<el-form-item label="产品名称:" prop="name">
<el-input v-model="item.name" autocomplete="off"/>
</el-form-item>
<el-form-item label="产品价格:" prop="price">
<el-input v-model="item.price" autocomplete="off"/>
</el-form-item>
<el-form-item label="优惠金额:" prop="discount">
<el-input v-model="item.discount" autocomplete="off"/>
</el-form-item>
<el-form-item label="有效期:" prop="days">
<el-input v-model.number="item.days" autocomplete="off" placeholder="会员有效期(天)"/>
</el-form-item>
<el-form-item label="调用次数:" prop="days">
<el-input v-model.number="item.calls" autocomplete="off" placeholder="增加调用次数"/>
</el-form-item>
<el-form-item label="启用状态:" prop="enable">
<el-switch v-model="item.enabled"/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showDialog = false">取消</el-button>
<el-button type="primary" @click="save">提交</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {onMounted, reactive, ref} from "vue";
import {httpGet, httpPost} from "@/utils/http";
import {ElMessage} from "element-plus";
import {dateFormat, removeArrayItem} from "@/utils/libs";
import {Plus} from "@element-plus/icons-vue";
import {Sortable} from "sortablejs";
//
const items = ref([])
const item = ref({})
const showDialog = ref(false)
const title = ref("")
const rules = reactive({
name: [{required: true, message: '请输入产品名称', trigger: 'change',}],
price: [{required: true, message: '请输产品价格', trigger: 'change',}],
discount: [{required: true, message: '请输优惠金额', trigger: 'change',}],
days: [{required: true, message: '请输入有效期', trigger: 'change',}],
})
const loading = ref(true)
const formRef = ref(null)
//
httpGet('/api/admin/product/list').then((res) => {
if (res.data) {
//
const arr = res.data;
for (let i = 0; i < arr.length; i++) {
arr[i].last_used_at = dateFormat(arr[i].last_used_at)
}
items.value = arr
}
loading.value = false
}).catch(() => {
ElMessage.error("获取数据失败");
})
onMounted(() => {
const drawBodyWrapper = document.querySelector('.el-table__body tbody')
//
Sortable.create(drawBodyWrapper, {
sort: true,
animation: 500,
onEnd({newIndex, oldIndex, from}) {
if (oldIndex === newIndex) {
return
}
const sortedData = Array.from(from.children).map(row => row.querySelector('.sort').getAttribute('data-id'));
const ids = []
const sorts = []
sortedData.forEach((id,index) => {
ids.push(parseInt(id))
sorts.push(index)
})
httpPost("/api/admin/product/sort", {ids: ids, sorts:sorts}).catch(e => {
ElMessage.error("排序失败:"+e.message)
})
}
})
})
const add = function () {
title.value = "新增模型"
showDialog.value = true
item.value = {}
}
const edit = function (row) {
title.value = "修改模型"
showDialog.value = true
item.value = row
}
const save = function () {
formRef.value.validate((valid) => {
if (valid) {
showDialog.value = false
item.value['price'] = parseFloat(item.value['price'])
item.value['discount'] = parseFloat(item.value['discount'])
httpPost('/api/admin/product/save', item.value).then((res) => {
ElMessage.success('操作成功!')
if (!item.value['id']) {
const newItem = res.data
items.value.push(newItem)
}
}).catch((e) => {
ElMessage.error('操作失败,' + e.message)
})
} else {
return false
}
})
}
const enable = (row) => {
httpPost('/api/admin/product/enable', {id: row.id, enabled: row.enabled}).then(() => {
ElMessage.success("操作成功!")
}).catch(e => {
ElMessage.error("操作失败:"+e.message)
})
}
const remove = function (row) {
httpGet('/api/admin/product/remove?id=' + row.id).then(() => {
ElMessage.success("删除成功!")
items.value = removeArrayItem(items.value, row, (v1, v2) => {
return v1.id === v2.id
})
}).catch((e) => {
ElMessage.error("删除失败:" + e.message)
})
}
</script>
<style lang="stylus" scoped>
.list {
.opt-box {
padding-bottom: 10px;
display flex;
justify-content flex-end
.el-icon {
margin-right: 5px;
}
}
.el-select {
width: 100%
}
}
</style>

View File

@ -12,6 +12,9 @@
<el-form-item label="注册赠送对话次数" prop="user_init_calls">
<el-input v-model.number="system['user_init_calls']" placeholder="新用户注册赠送对话次数"/>
</el-form-item>
<el-form-item label="VIP每月对话次数" prop="vip_month_calls">
<el-input v-model.number="system['vip_month_calls']" placeholder="VIP用户每月赠送对话次数"/>
</el-form-item>
<el-form-item label="注册赠送绘图次数" prop="init_img_calls">
<el-input v-model.number="system['init_img_calls']" placeholder="新用户注册赠送绘图次数"/>
</el-form-item>
@ -87,6 +90,36 @@
</template>
</el-input>
</el-form-item>
<el-form-item label="启用支付宝" prop="enabled_alipay">
<el-switch v-model="system['enabled_alipay']"/>
<el-tooltip
effect="dark"
content="是否启用支付宝支付功能,<br />请先在 config.toml 配置文件配置支付秘钥"
raw-content
placement="right"
>
<el-icon>
<InfoFilled/>
</el-icon>
</el-tooltip>
</el-form-item>
<el-form-item label="订单超时时间" prop="order_pay_timeout">
<div class="tip-input">
<el-input v-model.number="system['order_pay_timeout']" placeholder="单位:秒"/>
<div class="info">
<el-tooltip
effect="dark"
content="系统会定期清理超时未支付的订单<br/>默认值900秒"
raw-content
placement="right"
>
<el-icon>
<InfoFilled/>
</el-icon>
</el-tooltip>
</div>
</div>
</el-form-item>
<el-form-item label="默认AI模型" prop="default_models">
<template #default>
<div class="tip-input">

View File

@ -11,7 +11,12 @@
<el-table :data="users.items" border class="table" :row-key="row => row.id"
@selection-change="handleSelectionChange" table-layout="auto">
<el-table-column type="selection" width="38"/>
<el-table-column prop="mobile" label="账号"/>
<el-table-column prop="mobile" label="账号">
<template #default="scope">
<span>{{ scope.row.mobile }}</span>
<el-image v-if="scope.row.vip" :src="vipImg" style="height: 20px;position: relative; top:5px; left: 5px"/>
</template>
</el-table-column>
<el-table-column prop="calls" label="剩余对话次数"/>
<el-table-column prop="img_calls" label="剩余绘图次数"/>
<el-table-column prop="total_tokens" label="累计消耗tokens"/>
@ -74,7 +79,7 @@
<el-input v-model.number="user.calls" autocomplete="off" placeholder="0"/>
</el-form-item>
<el-form-item label="绘图次数:" prop="img_calls">
<el-input v-model.number="user.img_calls" autocomplete="off" placeholder="0"/>
<el-input v-model.number="user['img_calls']" autocomplete="off" placeholder="0"/>
</el-form-item>
<el-form-item label="有效期:" prop="expired_time">
@ -124,6 +129,10 @@
<el-form-item label="启用状态">
<el-switch v-model="user.status"/>
</el-form-item>
<el-form-item label="开通VIP">
<el-switch v-model="user.vip"/>
</el-form-item>
</el-form>
<template #footer>
@ -140,8 +149,8 @@
width="50%"
>
<el-form label-width="100px" ref="userEditFormRef">
<el-form-item label="用户名">
<el-input v-model="pass.username" autocomplete="off" readonly disabled/>
<el-form-item label="账户">
<el-input v-model="pass.mobile" autocomplete="off" readonly disabled/>
</el-form-item>
<el-form-item label="新密码:">
@ -168,18 +177,18 @@ import {Plus, Search} from "@element-plus/icons-vue";
//
const users = ref({page: 1, page_size: 15, items: []})
const query = ref({username: '', mobile: '', page: 1, page_size: 15})
const query = ref({mobile: '', page: 1, page_size: 15})
const title = ref('添加用户')
const vipImg = ref("/images/vip.png")
const add = ref(true)
const user = ref({chat_roles: [], chat_models: []})
const pass = ref({username: '', password: '', id: 0})
const pass = ref({mobile: '', password: '', id: 0})
const roles = ref([])
const models = ref([])
const showUserEditDialog = ref(false)
const showResetPassDialog = ref(false)
const rules = reactive({
username: [{required: true, message: '请输入用户名', trigger: 'change',}],
nickname: [{required: true, message: '请输入昵称', trigger: 'change',}],
password: [{required: true, message: '请输入密码', trigger: 'change',}],
mobile: [{required: true, message: '请输入手机号码', trigger: 'change',}],
@ -300,7 +309,7 @@ const handleSelectionChange = function (rows) {
const resetPass = (row) => {
showResetPassDialog.value = true
pass.value.id = row.id
pass.value.username = row.username
pass.value.mobile = row.mobile
}
const doResetPass = () => {

View File

@ -78,7 +78,7 @@ import {ref} from "vue";
import {httpGet, httpPost} from "@/utils/http";
import {showConfirmDialog, showFailToast, showSuccessToast} from "vant";
import {checkSession} from "@/action/session";
import router from "@/router";
import {router} from "@/router";
import {setChatConfig} from "@/store/chat";
import {removeArrayItem} from "@/utils/libs";
import BindMobile from "@/components/mobile/BindMobile.vue";