Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d83b5472a | |||
| e729b22197 | |||
| 5f67d2a28b | |||
| d586a567e4 | |||
| 6afaa58d28 | |||
| f995a868e4 | |||
| 5b9dcf1bda | |||
| d75a046791 | |||
| 209645e26b | |||
| 6ff8c7ab03 | |||
| c31343ac76 | |||
| b2e62a44ee | |||
| 9253426223 | |||
| 209d90e861 | |||
| e2807c5f95 | |||
| 45cc95a25c | |||
| 283474020d | |||
| 47d7bca268 | |||
| dd57eeb514 | |||
| 22e509c1ef | |||
| 3cad6b9d7f | |||
| 8aaec8b1cc | |||
| b2a40d3381 | |||
| bf130c5cde | |||
| f7adf02eb4 | |||
| d0c2d2c6fb | |||
| ee7cedd577 | |||
| 8c8661d0d7 | |||
| d15e14b117 | |||
| 3ab65a8221 | |||
| 7cfaf6c335 | |||
| 2bedd31b42 | |||
| c20060931b | |||
| 8b22161527 | |||
| 3d0ac2d049 | |||
| b81d3427ee | |||
| b4df9955f4 | |||
| 59c582d13c | |||
| 2819e3a1d1 | |||
| ed7f839911 | |||
| 040e8c1da8 | |||
| 0664bb3f65 | |||
| c7cf20391e | |||
| b07f0b9626 | |||
| 53cf37a469 | |||
| 3bda738ec1 | |||
| 160cb28572 | |||
| 274307b0a9 | |||
| a19a63b98c | |||
| 78e4cb3cad | |||
| c734db34e8 | |||
| a18ea3cc16 | |||
| aafbd78887 | |||
| 77897a8101 | |||
| 9b4ffb0875 | |||
| 606a4eee96 | |||
| 9ffb85a36b | |||
| c3b8fa29b2 | |||
| a057eddac1 | |||
| 1110403750 | |||
| 3a2aecbc01 | |||
| 49648d8b80 | |||
| 59d5aef393 | |||
| 48695e0e6f | |||
| e96ca77542 | |||
| 1ad2557668 | |||
| ded3bb9cb1 | |||
| dc83c4af31 | |||
| cf1b485389 | |||
| 741aaf4436 | |||
| c66636a0c7 | |||
| f7cdc727df | |||
| 07843d7898 | |||
| 559c98f261 | |||
| 960bf9c49e | |||
| 12a48c620e | |||
| eacc245bad | |||
| 03758a4a85 | |||
| 8fc0eb78e2 | |||
| 427fb7eaf6 | |||
| 4cd0e3651d | |||
| 677d02f2ab | |||
| c583382af7 | |||
| b1950655e7 | |||
| 22cbbcab4c | |||
| 873067f7d1 | |||
| 82c2008d2c | |||
| 495e4f5e17 | |||
| 23fde25b15 | |||
| ac90d9f185 | |||
| bb5b9eaca2 | |||
| b713e277cd | |||
| 08a5243bbc | |||
| c9611c493f | |||
| 314ae40820 | |||
| 116e0b8f1c | |||
| 70560d5371 |
@@ -19,6 +19,8 @@
|
||||
# HOSTNAME=your-hostname
|
||||
|
||||
# 数据库相关配置
|
||||
# 启用错误日志记录
|
||||
# ERROR_LOG_ENABLED=true
|
||||
# 数据库连接字符串
|
||||
# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true
|
||||
# 日志数据库连接字符串
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# ⚠️ 提交说明 / PR Notice
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> - 请提供**人工撰写**的简洁摘要,避免直接粘贴未经整理的 AI 输出。
|
||||
|
||||
## 📝 变更描述 / Description
|
||||
(简述:做了什么?为什么这样改能生效?请基于你对代码逻辑的理解来写,避免粘贴未经整理的内容)
|
||||
|
||||
## 🚀 变更类型 / Type of change
|
||||
- [ ] 🐛 Bug 修复 (Bug fix) - *请关联对应 Issue,避免将设计取舍、理解偏差或预期不一致直接归类为 bug*
|
||||
- [ ] ✨ 新功能 (New feature) - *重大特性建议先通过 Issue 沟通*
|
||||
- [ ] ⚡ 性能优化 / 重构 (Refactor)
|
||||
- [ ] 📝 文档更新 (Documentation)
|
||||
|
||||
## 🔗 关联任务 / Related Issue
|
||||
- Closes # (如有)
|
||||
|
||||
## ✅ 提交前检查项 / Checklist
|
||||
- [ ] **人工确认:** 我已亲自整理并撰写此描述,没有直接粘贴未经处理的 AI 输出。
|
||||
- [ ] **非重复提交:** 我已搜索现有的 [Issues](https://github.com/QuantumNous/new-api/issues) 与 [PRs](https://github.com/QuantumNous/new-api/pulls),确认不是重复提交。
|
||||
- [ ] **Bug fix 说明:** 若此 PR 标记为 `Bug fix`,我已提交或关联对应 Issue,且不会将设计取舍、预期不一致或理解偏差直接归类为 bug。
|
||||
- [ ] **变更理解:** 我已理解这些更改的工作原理及可能影响。
|
||||
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
|
||||
- [ ] **本地验证:** 已在本地运行并通过测试或手动验证,维护者可以据此复核结果。
|
||||
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
|
||||
|
||||
## 📸 运行证明 / Proof of Work
|
||||
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
|
||||
@@ -1,29 +0,0 @@
|
||||
# ⚠️ 提交警告 / PR Warning
|
||||
> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。
|
||||
|
||||
---
|
||||
|
||||
## 💡 沟通提示 / Pre-submission
|
||||
> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。
|
||||
|
||||
## 📝 变更描述 / Description
|
||||
(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话)
|
||||
|
||||
## 🚀 变更类型 / Type of change
|
||||
- [ ] 🐛 Bug 修复 (Bug fix)
|
||||
- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通*
|
||||
- [ ] ⚡ 性能优化 / 重构 (Refactor)
|
||||
- [ ] 📝 文档更新 (Documentation)
|
||||
|
||||
## 🔗 关联任务 / Related Issue
|
||||
- Closes # (如有)
|
||||
|
||||
## ✅ 提交前检查项 / Checklist
|
||||
- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。
|
||||
- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。
|
||||
- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。
|
||||
- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。
|
||||
- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。
|
||||
|
||||
## 📸 运行证明 / Proof of Work
|
||||
(请在此粘贴截图、关键日志或测试报告,以证明变更生效)
|
||||
@@ -0,0 +1,33 @@
|
||||
name: PR Check
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0.2.1
|
||||
with:
|
||||
max-failures: 4
|
||||
require-description: true
|
||||
|
||||
# require-linked-issue: false
|
||||
blocked-terms: |
|
||||
🤖 Generated with Claude Code
|
||||
|
||||
require-pr-template: true
|
||||
strict-pr-template-sections: "✅ 提交前检查项 / Checklist"
|
||||
|
||||
detect-spam-usernames: true
|
||||
min-account-age: 30
|
||||
|
||||
failure-add-pr-labels: "pr-check-failed"
|
||||
failure-pr-message: "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容 (AI Slop),我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。/ Thank you for your submission. This PR has been closed because it does not follow our contribution template and has been identified as purely AI-generated content (AI Slop) without meaningful human involvement. We prioritize contributions that are human-verified and reflect individual effort. If you believe this is a mistake, please let us know by replying to this comment."
|
||||
close-pr: true
|
||||
@@ -29,3 +29,5 @@ data/
|
||||
.gomodcache/
|
||||
.gocache-temp
|
||||
.gopath
|
||||
|
||||
token_estimator_test.go
|
||||
@@ -80,6 +80,7 @@ var InsecureTLSConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
var SMTPServer = ""
|
||||
var SMTPPort = 587
|
||||
var SMTPSSLEnabled = false
|
||||
var SMTPForceAuthLogin = false
|
||||
var SMTPAccount = ""
|
||||
var SMTPFrom = ""
|
||||
var SMTPToken = ""
|
||||
@@ -115,6 +116,10 @@ var RetryTimes = 0
|
||||
|
||||
var IsMasterNode bool
|
||||
|
||||
// NodeName 节点名称,从 NODE_NAME 环境变量读取;
|
||||
// 用于审计日志中标识节点身份,在容器/K8s 部署时比自动探测到的容器内网 IP 更具可读性。
|
||||
var NodeName = ""
|
||||
|
||||
var requestInterval int
|
||||
var RequestInterval time.Duration
|
||||
|
||||
|
||||
+15
-4
@@ -19,6 +19,20 @@ func generateMessageID() (string, error) {
|
||||
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil
|
||||
}
|
||||
|
||||
func shouldUseSMTPLoginAuth() bool {
|
||||
if SMTPForceAuthLogin {
|
||||
return true
|
||||
}
|
||||
return isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer)
|
||||
}
|
||||
|
||||
func getSMTPAuth() smtp.Auth {
|
||||
if shouldUseSMTPLoginAuth() {
|
||||
return LoginAuth(SMTPAccount, SMTPToken)
|
||||
}
|
||||
return smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
|
||||
}
|
||||
|
||||
func SendEmail(subject string, receiver string, content string) error {
|
||||
if SMTPFrom == "" { // for compatibility
|
||||
SMTPFrom = SMTPAccount
|
||||
@@ -38,7 +52,7 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
"Message-ID: %s\r\n"+ // 添加 Message-ID 头
|
||||
"Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n",
|
||||
receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content))
|
||||
auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer)
|
||||
auth := getSMTPAuth()
|
||||
addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort)
|
||||
to := strings.Split(receiver, ";")
|
||||
var err error
|
||||
@@ -80,9 +94,6 @@ func SendEmail(subject string, receiver string, content string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) {
|
||||
auth = LoginAuth(SMTPAccount, SMTPToken)
|
||||
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
||||
} else {
|
||||
err = smtp.SendMail(addr, auth, SMTPFrom, to, mail)
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ func InitEnv() {
|
||||
DebugEnabled = os.Getenv("DEBUG") == "true"
|
||||
MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true"
|
||||
IsMasterNode = os.Getenv("NODE_TYPE") != "slave"
|
||||
NodeName = os.Getenv("NODE_NAME")
|
||||
TLSInsecureSkipVerify = GetEnvOrDefaultBool("TLS_INSECURE_SKIP_VERIFY", false)
|
||||
if TLSInsecureSkipVerify {
|
||||
if tr, ok := http.DefaultTransport.(*http.Transport); ok && tr != nil {
|
||||
|
||||
+72
-28
@@ -29,45 +29,89 @@ var DefaultSSRFProtection = &SSRFProtection{
|
||||
AllowedPorts: []int{},
|
||||
}
|
||||
|
||||
// isPrivateIP 检查IP是否为私有地址
|
||||
// privateIPv4Nets IPv4 私有/保留/特殊用途网段
|
||||
// 参考 IANA IPv4 Special-Purpose Address Registry
|
||||
// https://www.iana.org/assignments/iana-ipv4-special-registry/
|
||||
var privateIPv4Nets = []net.IPNet{
|
||||
{IP: net.IPv4(0, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 0.0.0.0/8 ("This network" / 未指定)
|
||||
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8 (私有)
|
||||
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10 (运营商级 NAT / CGNAT)
|
||||
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8 (回环)
|
||||
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
|
||||
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12 (私有)
|
||||
{IP: net.IPv4(192, 0, 0, 0), Mask: net.CIDRMask(24, 32)}, // 192.0.0.0/24 (IETF 协议分配)
|
||||
{IP: net.IPv4(192, 0, 2, 0), Mask: net.CIDRMask(24, 32)}, // 192.0.2.0/24 (TEST-NET-1)
|
||||
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16 (私有)
|
||||
{IP: net.IPv4(198, 18, 0, 0), Mask: net.CIDRMask(15, 32)}, // 198.18.0.0/15 (基准测试)
|
||||
{IP: net.IPv4(198, 51, 100, 0), Mask: net.CIDRMask(24, 32)}, // 198.51.100.0/24 (TEST-NET-2)
|
||||
{IP: net.IPv4(203, 0, 113, 0), Mask: net.CIDRMask(24, 32)}, // 203.0.113.0/24 (TEST-NET-3)
|
||||
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
|
||||
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
|
||||
{IP: net.IPv4(255, 255, 255, 255), Mask: net.CIDRMask(32, 32)}, // 255.255.255.255/32 (受限广播)
|
||||
}
|
||||
|
||||
// privateIPv6Nets IPv6 私有/保留/特殊用途网段
|
||||
// 参考 IANA IPv6 Special-Purpose Address Registry
|
||||
// https://www.iana.org/assignments/iana-ipv6-special-registry/
|
||||
var privateIPv6Nets = func() []net.IPNet {
|
||||
cidrs := []string{
|
||||
"::/128", // 未指定地址
|
||||
"::1/128", // 回环
|
||||
"::ffff:0:0/96", // IPv4-mapped
|
||||
"64:ff9b::/96", // IPv4/IPv6 translation
|
||||
"100::/64", // Discard-Only
|
||||
"2001::/23", // IETF Protocol Assignments
|
||||
"2001:db8::/32", // 文档
|
||||
"fc00::/7", // Unique Local Address (ULA)
|
||||
"fe80::/10", // 链路本地
|
||||
"ff00::/8", // 组播
|
||||
}
|
||||
nets := make([]net.IPNet, 0, len(cidrs))
|
||||
for _, c := range cidrs {
|
||||
if _, n, err := net.ParseCIDR(c); err == nil && n != nil {
|
||||
nets = append(nets, *n)
|
||||
}
|
||||
}
|
||||
return nets
|
||||
}()
|
||||
|
||||
// isPrivateIP 检查IP是否为私有/保留/特殊用途地址
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return true
|
||||
}
|
||||
// 未指定地址 (0.0.0.0, ::)
|
||||
if ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
// 回环、链路本地 (unicast/multicast)
|
||||
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查私有网段
|
||||
private := []net.IPNet{
|
||||
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
|
||||
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
|
||||
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
|
||||
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
|
||||
{IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地)
|
||||
{IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播)
|
||||
{IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留)
|
||||
// 接口本地组播 (IPv6 ff01::/16 等)
|
||||
if ip.IsInterfaceLocalMulticast() {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, privateNet := range private {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
for _, privateNet := range privateIPv4Nets {
|
||||
if privateNet.Contains(v4) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IPv6 检查
|
||||
for _, privateNet := range privateIPv6Nets {
|
||||
if privateNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 检查IPv6私有地址
|
||||
if ip.To4() == nil {
|
||||
// IPv6 loopback
|
||||
if ip.Equal(net.IPv6loopback) {
|
||||
return true
|
||||
}
|
||||
// IPv6 link-local
|
||||
if strings.HasPrefix(ip.String(), "fe80:") {
|
||||
return true
|
||||
}
|
||||
// IPv6 unique local
|
||||
if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") {
|
||||
return true
|
||||
}
|
||||
// 兜底: Go 标准库识别的其他私有地址
|
||||
if ip.IsPrivate() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -65,4 +65,5 @@ const (
|
||||
|
||||
// ContextKeyLanguage stores the user's language preference for i18n
|
||||
ContextKeyLanguage ContextKey = "language"
|
||||
ContextKeyIsStream ContextKey = "is_stream"
|
||||
)
|
||||
|
||||
@@ -150,6 +150,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
}
|
||||
}
|
||||
cache.WriteContext(c)
|
||||
c.Set("id", 1)
|
||||
|
||||
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
@@ -274,7 +275,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: err,
|
||||
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError),
|
||||
newAPIError: types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +460,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError),
|
||||
}
|
||||
}
|
||||
if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
|
||||
if bodyErr := validateTestResponseBody(respBody, isStream); bodyErr != nil {
|
||||
return testResult{
|
||||
context: c,
|
||||
localErr: bodyErr,
|
||||
@@ -569,6 +570,42 @@ func detectErrorFromTestResponseBody(respBody []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStreamTestResponseBody(respBody []byte) error {
|
||||
b := bytes.TrimSpace(respBody)
|
||||
if len(b) == 0 {
|
||||
return errors.New("stream response body is empty")
|
||||
}
|
||||
|
||||
for _, line := range bytes.Split(b, []byte{'\n'}) {
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 || !bytes.HasPrefix(line, []byte("data:")) {
|
||||
continue
|
||||
}
|
||||
payload := bytes.TrimSpace(bytes.TrimPrefix(line, []byte("data:")))
|
||||
if len(payload) == 0 || bytes.Equal(payload, []byte("[DONE]")) {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return errors.New("stream response body does not contain a valid stream event")
|
||||
}
|
||||
|
||||
func validateTestResponseBody(respBody []byte, isStream bool) error {
|
||||
if bodyErr := detectErrorFromTestResponseBody(respBody); bodyErr != nil {
|
||||
return bodyErr
|
||||
}
|
||||
if isStream {
|
||||
return validateStreamTestResponseBody(respBody)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldUseStreamForAutomaticChannelTest(channel *model.Channel) bool {
|
||||
return channel != nil && channel.Type == constant.ChannelTypeCodex
|
||||
}
|
||||
|
||||
func detectErrorMessageFromJSONBytes(jsonBytes []byte) string {
|
||||
if len(jsonBytes) == 0 {
|
||||
return ""
|
||||
@@ -756,11 +793,15 @@ func TestChannel(c *gin.Context) {
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, testModel, endpointType, isStream)
|
||||
if result.localErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
resp := gin.H{
|
||||
"success": false,
|
||||
"message": result.localErr.Error(),
|
||||
"time": 0.0,
|
||||
})
|
||||
}
|
||||
if result.newAPIError != nil {
|
||||
resp["error_code"] = result.newAPIError.GetErrorCode()
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
tok := time.Now()
|
||||
@@ -769,9 +810,10 @@ func TestChannel(c *gin.Context) {
|
||||
consumedTime := float64(milliseconds) / 1000.0
|
||||
if result.newAPIError != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": result.newAPIError.Error(),
|
||||
"time": consumedTime,
|
||||
"success": false,
|
||||
"message": result.newAPIError.Error(),
|
||||
"time": consumedTime,
|
||||
"error_code": result.newAPIError.GetErrorCode(),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -816,7 +858,7 @@ func testAllChannels(notify bool) error {
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, "", "", false)
|
||||
result := testChannel(channel, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
|
||||
@@ -824,7 +866,7 @@ func testAllChannels(notify bool) error {
|
||||
newAPIError := result.newAPIError
|
||||
// request error disables the channel
|
||||
if newAPIError != nil {
|
||||
shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError)
|
||||
shouldBanChannel = service.ShouldDisableChannel(result.newAPIError)
|
||||
}
|
||||
|
||||
// 当错误检查通过,才检查响应时间
|
||||
|
||||
+12
-2
@@ -27,6 +27,15 @@ var completionRatioMetaOptionKeys = []string{
|
||||
"AudioCompletionRatio",
|
||||
}
|
||||
|
||||
func isVisiblePublicKeyOption(key string) bool {
|
||||
switch key {
|
||||
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
@@ -66,11 +75,12 @@ func GetOptions(c *gin.Context) {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
for k, v := range common.OptionMap {
|
||||
value := common.Interface2String(v)
|
||||
if strings.HasSuffix(k, "Token") ||
|
||||
isSensitiveKey := strings.HasSuffix(k, "Token") ||
|
||||
strings.HasSuffix(k, "Secret") ||
|
||||
strings.HasSuffix(k, "Key") ||
|
||||
strings.HasSuffix(k, "secret") ||
|
||||
strings.HasSuffix(k, "api_key") {
|
||||
strings.HasSuffix(k, "api_key")
|
||||
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
|
||||
continue
|
||||
}
|
||||
options = append(options, &model.Option{
|
||||
|
||||
@@ -36,6 +36,10 @@ func PasskeyRegisterBegin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requirePasskeyRegistrationVerification(c, user.Id) {
|
||||
return
|
||||
}
|
||||
|
||||
credential, err := model.GetPasskeyByUserID(user.Id)
|
||||
if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
common.ApiError(c, err)
|
||||
@@ -96,6 +100,10 @@ func PasskeyRegisterFinish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requirePasskeyRegistrationVerification(c, user.Id) {
|
||||
return
|
||||
}
|
||||
|
||||
wa, err := passkeysvc.BuildWebAuthn(c.Request)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
@@ -151,6 +159,10 @@ func PasskeyDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !requirePasskeyDeleteVerification(c, user.Id) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DeletePasskeyByUserID(user.Id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -474,6 +486,7 @@ func PasskeyVerifyFinish(c *gin.Context) {
|
||||
// Mark passkey as ready; /api/verify will convert this into the final secure verification session.
|
||||
session.Set(PasskeyReadySessionKey, time.Now().Unix())
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
session.Delete(secureVerificationMethodSessionKey)
|
||||
if err := session.Save(); err != nil {
|
||||
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
|
||||
return
|
||||
@@ -504,3 +517,60 @@ func getSessionUser(c *gin.Context) (*model.User, error) {
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func requirePasskeyRegistrationVerification(c *gin.Context, userID int) bool {
|
||||
twoFA, err := model.GetTwoFAByUserId(userID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
if twoFA == nil || !twoFA.IsEnabled {
|
||||
return true
|
||||
}
|
||||
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
|
||||
}
|
||||
|
||||
func requirePasskeyDeleteVerification(c *gin.Context, userID int) bool {
|
||||
twoFA, err := model.GetTwoFAByUserId(userID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
if twoFA != nil && twoFA.IsEnabled {
|
||||
return requireSecureVerificationMethod(c, secureVerificationMethod2FA)
|
||||
}
|
||||
|
||||
_, err = model.GetPasskeyByUserID(userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "该用户尚未绑定 Passkey",
|
||||
})
|
||||
return false
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return requireSecureVerificationMethod(c, secureVerificationMethodPasskey)
|
||||
}
|
||||
|
||||
func requireSecureVerificationMethod(c *gin.Context, method string) bool {
|
||||
session := sessions.Default(c)
|
||||
verifiedAt, ok := session.Get(SecureVerificationSessionKey).(int64)
|
||||
if !ok || time.Now().Unix()-verifiedAt >= SecureVerificationTimeout {
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
session.Delete(secureVerificationMethodSessionKey)
|
||||
_ = session.Save()
|
||||
common.ApiErrorMsg(c, "请先完成安全验证")
|
||||
return false
|
||||
}
|
||||
|
||||
if verifiedMethod, ok := session.Get(secureVerificationMethodSessionKey).(string); !ok || verifiedMethod != method {
|
||||
common.ApiErrorMsg(c, "请先完成对应的安全验证")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
)
|
||||
|
||||
func isStripeTopUpEnabled() bool {
|
||||
return strings.TrimSpace(setting.StripeApiSecret) != "" &&
|
||||
strings.TrimSpace(setting.StripeWebhookSecret) != "" &&
|
||||
strings.TrimSpace(setting.StripePriceId) != ""
|
||||
}
|
||||
|
||||
func isStripeWebhookConfigured() bool {
|
||||
return strings.TrimSpace(setting.StripeWebhookSecret) != ""
|
||||
}
|
||||
|
||||
func isStripeWebhookEnabled() bool {
|
||||
return isStripeTopUpEnabled()
|
||||
}
|
||||
|
||||
func isCreemTopUpEnabled() bool {
|
||||
products := strings.TrimSpace(setting.CreemProducts)
|
||||
return strings.TrimSpace(setting.CreemApiKey) != "" &&
|
||||
products != "" &&
|
||||
products != "[]"
|
||||
}
|
||||
|
||||
func isCreemWebhookConfigured() bool {
|
||||
return strings.TrimSpace(setting.CreemWebhookSecret) != ""
|
||||
}
|
||||
|
||||
func isCreemWebhookEnabled() bool {
|
||||
return isCreemTopUpEnabled() && isCreemWebhookConfigured()
|
||||
}
|
||||
|
||||
func isWaffoTopUpEnabled() bool {
|
||||
if !setting.WaffoEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return isWaffoWebhookConfigured()
|
||||
}
|
||||
|
||||
func isWaffoWebhookConfigured() bool {
|
||||
if setting.WaffoSandbox {
|
||||
return strings.TrimSpace(setting.WaffoSandboxApiKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoSandboxPrivateKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoSandboxPublicCert) != ""
|
||||
}
|
||||
|
||||
return strings.TrimSpace(setting.WaffoApiKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPrivateKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPublicCert) != ""
|
||||
}
|
||||
|
||||
func isWaffoWebhookEnabled() bool {
|
||||
return isWaffoTopUpEnabled()
|
||||
}
|
||||
|
||||
func isWaffoPancakeTopUpEnabled() bool {
|
||||
if !setting.WaffoPancakeEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return isWaffoPancakeWebhookConfigured() &&
|
||||
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
|
||||
}
|
||||
|
||||
func isWaffoPancakeWebhookConfigured() bool {
|
||||
currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
|
||||
if setting.WaffoPancakeSandbox {
|
||||
currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
|
||||
}
|
||||
|
||||
return currentWebhookKey != ""
|
||||
}
|
||||
|
||||
func isWaffoPancakeWebhookEnabled() bool {
|
||||
return isWaffoPancakeTopUpEnabled()
|
||||
}
|
||||
|
||||
func isEpayTopUpEnabled() bool {
|
||||
return isEpayWebhookConfigured() && len(operation_setting.PayMethods) > 0
|
||||
}
|
||||
|
||||
func isEpayWebhookConfigured() bool {
|
||||
return strings.TrimSpace(operation_setting.PayAddress) != "" &&
|
||||
strings.TrimSpace(operation_setting.EpayId) != "" &&
|
||||
strings.TrimSpace(operation_setting.EpayKey) != ""
|
||||
}
|
||||
|
||||
func isEpayWebhookEnabled() bool {
|
||||
return isEpayTopUpEnabled()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStripeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
originalAPISecret := setting.StripeApiSecret
|
||||
originalWebhookSecret := setting.StripeWebhookSecret
|
||||
originalPriceID := setting.StripePriceId
|
||||
t.Cleanup(func() {
|
||||
setting.StripeApiSecret = originalAPISecret
|
||||
setting.StripeWebhookSecret = originalWebhookSecret
|
||||
setting.StripePriceId = originalPriceID
|
||||
})
|
||||
|
||||
setting.StripeWebhookSecret = ""
|
||||
setting.StripeApiSecret = "sk_test_123"
|
||||
setting.StripePriceId = "price_123"
|
||||
require.False(t, isStripeWebhookEnabled())
|
||||
|
||||
setting.StripeWebhookSecret = "whsec_test"
|
||||
require.True(t, isStripeWebhookEnabled())
|
||||
|
||||
setting.StripePriceId = ""
|
||||
require.False(t, isStripeWebhookEnabled())
|
||||
}
|
||||
|
||||
func TestCreemWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
originalAPIKey := setting.CreemApiKey
|
||||
originalProducts := setting.CreemProducts
|
||||
originalWebhookSecret := setting.CreemWebhookSecret
|
||||
t.Cleanup(func() {
|
||||
setting.CreemApiKey = originalAPIKey
|
||||
setting.CreemProducts = originalProducts
|
||||
setting.CreemWebhookSecret = originalWebhookSecret
|
||||
})
|
||||
|
||||
setting.CreemWebhookSecret = ""
|
||||
setting.CreemApiKey = "creem_api_key"
|
||||
setting.CreemProducts = `[{"productId":"prod_123"}]`
|
||||
require.False(t, isCreemWebhookEnabled())
|
||||
|
||||
setting.CreemWebhookSecret = "creem_secret"
|
||||
require.True(t, isCreemWebhookEnabled())
|
||||
|
||||
setting.CreemProducts = "[]"
|
||||
require.False(t, isCreemWebhookEnabled())
|
||||
}
|
||||
|
||||
func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
originalEnabled := setting.WaffoEnabled
|
||||
originalSandbox := setting.WaffoSandbox
|
||||
originalAPIKey := setting.WaffoApiKey
|
||||
originalPrivateKey := setting.WaffoPrivateKey
|
||||
originalPublicCert := setting.WaffoPublicCert
|
||||
originalSandboxAPIKey := setting.WaffoSandboxApiKey
|
||||
originalSandboxPrivateKey := setting.WaffoSandboxPrivateKey
|
||||
originalSandboxPublicCert := setting.WaffoSandboxPublicCert
|
||||
t.Cleanup(func() {
|
||||
setting.WaffoEnabled = originalEnabled
|
||||
setting.WaffoSandbox = originalSandbox
|
||||
setting.WaffoApiKey = originalAPIKey
|
||||
setting.WaffoPrivateKey = originalPrivateKey
|
||||
setting.WaffoPublicCert = originalPublicCert
|
||||
setting.WaffoSandboxApiKey = originalSandboxAPIKey
|
||||
setting.WaffoSandboxPrivateKey = originalSandboxPrivateKey
|
||||
setting.WaffoSandboxPublicCert = originalSandboxPublicCert
|
||||
})
|
||||
|
||||
setting.WaffoEnabled = true
|
||||
setting.WaffoSandbox = false
|
||||
setting.WaffoApiKey = ""
|
||||
setting.WaffoPrivateKey = "private"
|
||||
setting.WaffoPublicCert = "public"
|
||||
require.False(t, isWaffoWebhookEnabled())
|
||||
|
||||
setting.WaffoApiKey = "api"
|
||||
require.True(t, isWaffoWebhookEnabled())
|
||||
|
||||
setting.WaffoEnabled = false
|
||||
require.False(t, isWaffoWebhookEnabled())
|
||||
|
||||
setting.WaffoEnabled = true
|
||||
setting.WaffoSandbox = true
|
||||
setting.WaffoSandboxApiKey = ""
|
||||
setting.WaffoSandboxPrivateKey = "sandbox_private"
|
||||
setting.WaffoSandboxPublicCert = "sandbox_public"
|
||||
require.False(t, isWaffoWebhookEnabled())
|
||||
|
||||
setting.WaffoSandboxApiKey = "sandbox_api"
|
||||
require.True(t, isWaffoWebhookEnabled())
|
||||
}
|
||||
|
||||
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
originalEnabled := setting.WaffoPancakeEnabled
|
||||
originalSandbox := setting.WaffoPancakeSandbox
|
||||
originalMerchantID := setting.WaffoPancakeMerchantID
|
||||
originalPrivateKey := setting.WaffoPancakePrivateKey
|
||||
originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
|
||||
originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
|
||||
originalStoreID := setting.WaffoPancakeStoreID
|
||||
originalProductID := setting.WaffoPancakeProductID
|
||||
t.Cleanup(func() {
|
||||
setting.WaffoPancakeEnabled = originalEnabled
|
||||
setting.WaffoPancakeSandbox = originalSandbox
|
||||
setting.WaffoPancakeMerchantID = originalMerchantID
|
||||
setting.WaffoPancakePrivateKey = originalPrivateKey
|
||||
setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
|
||||
setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
|
||||
setting.WaffoPancakeStoreID = originalStoreID
|
||||
setting.WaffoPancakeProductID = originalProductID
|
||||
})
|
||||
|
||||
setting.WaffoPancakeEnabled = true
|
||||
setting.WaffoPancakeSandbox = false
|
||||
setting.WaffoPancakeMerchantID = "merchant"
|
||||
setting.WaffoPancakePrivateKey = "private"
|
||||
setting.WaffoPancakeStoreID = "store"
|
||||
setting.WaffoPancakeProductID = "product"
|
||||
setting.WaffoPancakeWebhookPublicKey = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeWebhookPublicKey = "public"
|
||||
require.True(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeEnabled = false
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeEnabled = true
|
||||
setting.WaffoPancakeSandbox = true
|
||||
setting.WaffoPancakeWebhookTestKey = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeWebhookTestKey = "test_public"
|
||||
require.True(t, isWaffoPancakeWebhookEnabled())
|
||||
}
|
||||
|
||||
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
originalPayAddress := operation_setting.PayAddress
|
||||
originalEpayID := operation_setting.EpayId
|
||||
originalEpayKey := operation_setting.EpayKey
|
||||
originalPayMethods := operation_setting.PayMethods
|
||||
t.Cleanup(func() {
|
||||
operation_setting.PayAddress = originalPayAddress
|
||||
operation_setting.EpayId = originalEpayID
|
||||
operation_setting.EpayKey = originalEpayKey
|
||||
operation_setting.PayMethods = originalPayMethods
|
||||
})
|
||||
|
||||
operation_setting.PayAddress = "https://pay.example.com"
|
||||
operation_setting.EpayId = "epay_id"
|
||||
operation_setting.EpayKey = ""
|
||||
operation_setting.PayMethods = []map[string]string{{"type": "alipay"}}
|
||||
require.False(t, isEpayWebhookEnabled())
|
||||
|
||||
operation_setting.EpayKey = "epay_key"
|
||||
require.True(t, isEpayWebhookEnabled())
|
||||
|
||||
operation_setting.PayMethods = nil
|
||||
require.False(t, isEpayWebhookEnabled())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
@@ -8,6 +9,30 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing {
|
||||
if len(pricing) == 0 {
|
||||
return pricing
|
||||
}
|
||||
if len(usableGroup) == 0 {
|
||||
return []model.Pricing{}
|
||||
}
|
||||
|
||||
filtered := make([]model.Pricing, 0, len(pricing))
|
||||
for _, item := range pricing {
|
||||
if common.StringsContains(item.EnableGroup, "all") {
|
||||
filtered = append(filtered, item)
|
||||
continue
|
||||
}
|
||||
for _, group := range item.EnableGroup {
|
||||
if _, ok := usableGroup[group]; ok {
|
||||
filtered = append(filtered, item)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func GetPricing(c *gin.Context) {
|
||||
pricing := model.GetPricing()
|
||||
userId, exists := c.Get("id")
|
||||
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
|
||||
}
|
||||
|
||||
usableGroup = service.GetUserUsableGroups(group)
|
||||
pricing = filterPricingByUsableGroups(pricing, usableGroup)
|
||||
// check groupRatio contains usableGroup
|
||||
for group := range ratio_setting.GetGroupRatioCopy() {
|
||||
if _, ok := usableGroup[group]; !ok {
|
||||
|
||||
+4
-4
@@ -151,7 +151,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta)
|
||||
if err != nil {
|
||||
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError)
|
||||
newAPIError = types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithStatusCode(http.StatusBadRequest))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(channelError.ChannelType, err) && channelError.AutoBan {
|
||||
if service.ShouldDisableChannel(err) && channelError.AutoBan {
|
||||
gopool.Go(func() {
|
||||
service.DisableChannel(channelError, err.ErrorWithStatusCode())
|
||||
})
|
||||
@@ -389,7 +389,7 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
|
||||
startTime = time.Now()
|
||||
}
|
||||
useTimeSeconds := int(time.Since(startTime).Seconds())
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, false, userGroup, other)
|
||||
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, common.GetContextKeyBool(c, constant.ContextKeyIsStream), userGroup, other)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -581,7 +581,7 @@ func RelayTask(c *gin.Context) {
|
||||
ModelRatio: relayInfo.PriceData.ModelRatio,
|
||||
OtherRatios: relayInfo.PriceData.OtherRatios,
|
||||
OriginModelName: relayInfo.OriginModelName,
|
||||
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName),
|
||||
PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName) || relayInfo.PriceData.UsePrice,
|
||||
}
|
||||
task.Quota = result.Quota
|
||||
task.Data = result.TaskData
|
||||
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
|
||||
const (
|
||||
// SecureVerificationSessionKey means the user has fully passed secure verification.
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
secureVerificationMethodSessionKey = "secure_verified_method"
|
||||
secureVerificationMethod2FA = "2fa"
|
||||
secureVerificationMethodPasskey = "passkey"
|
||||
// PasskeyReadySessionKey means WebAuthn finished and /api/verify can finalize step-up verification.
|
||||
PasskeyReadySessionKey = "secure_passkey_ready_at"
|
||||
// SecureVerificationTimeout 验证有效期(秒)
|
||||
@@ -120,7 +123,7 @@ func UniversalVerify(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 验证成功,在 session 中记录时间戳
|
||||
now, err := setSecureVerificationSession(c)
|
||||
now, err := setSecureVerificationSession(c, req.Method)
|
||||
if err != nil {
|
||||
common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err))
|
||||
return
|
||||
@@ -139,11 +142,12 @@ func UniversalVerify(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func setSecureVerificationSession(c *gin.Context) (int64, error) {
|
||||
func setSecureVerificationSession(c *gin.Context, method string) (int64, error) {
|
||||
session := sessions.Default(c)
|
||||
session.Delete(PasskeyReadySessionKey)
|
||||
now := time.Now().Unix()
|
||||
session.Set(SecureVerificationSessionKey, now)
|
||||
session.Set(secureVerificationMethodSessionKey, method)
|
||||
if err := session.Save(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -24,14 +26,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
// Keep body for debugging consistency (like RequestCreemPay)
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("read subscription creem pay req body err: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付请求读取失败 error=%q", err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -85,12 +87,12 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodCreem,
|
||||
PaymentMethod: model.PaymentMethodCreem,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -112,14 +114,14 @@ func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
Quota: 0,
|
||||
}
|
||||
|
||||
checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username)
|
||||
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, product, user.Email, user.Username)
|
||||
if err != nil {
|
||||
log.Printf("获取Creem支付链接失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅支付链接创建失败 trade_no=%s product_id=%s error=%q", referenceId, product.ProductId, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": checkoutUrl,
|
||||
|
||||
@@ -104,7 +104,7 @@ func SubscriptionRequestEpay(c *gin.Context) {
|
||||
ReturnUrl: returnUrl,
|
||||
})
|
||||
if err != nil {
|
||||
_ = model.ExpireSubscriptionOrder(tradeNo)
|
||||
_ = model.ExpireSubscriptionOrder(tradeNo, req.PaymentMethod)
|
||||
common.ApiErrorMsg(c, "拉起支付失败")
|
||||
return
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func SubscriptionEpayNotify(c *gin.Context) {
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), verifyInfo.Type); err != nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
|
||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), verifyInfo.Type); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
@@ -78,7 +78,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
|
||||
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
|
||||
if err != nil {
|
||||
log.Println("获取Stripe Checkout支付链接失败", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 订阅支付链接创建失败 trade_no=%s plan_id=%d error=%q", referenceId, plan.Id, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodStripe,
|
||||
PaymentMethod: model.PaymentMethodStripe,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
|
||||
@@ -334,3 +334,26 @@ func DeleteTokenBatch(c *gin.Context) {
|
||||
"data": count,
|
||||
})
|
||||
}
|
||||
|
||||
func GetTokenKeysBatch(c *gin.Context) {
|
||||
tokenBatch := TokenBatch{}
|
||||
if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
if len(tokenBatch.Ids) > 100 {
|
||||
common.ApiErrorI18n(c, i18n.MsgBatchTooMany, map[string]any{"Max": 100})
|
||||
return
|
||||
}
|
||||
userId := c.GetInt("id")
|
||||
tokens, err := model.GetTokenKeysByIds(tokenBatch.Ids, userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
keysMap := make(map[int]string)
|
||||
for _, t := range tokens {
|
||||
keysMap[t.Id] = t.GetFullKey()
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"keys": keysMap})
|
||||
}
|
||||
|
||||
+103
-57
@@ -2,7 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -27,7 +27,7 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
payMethods := operation_setting.PayMethods
|
||||
|
||||
// 如果启用了 Stripe 支付,添加到支付方法列表
|
||||
if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" {
|
||||
if isStripeTopUpEnabled() {
|
||||
// 检查是否已经包含 Stripe
|
||||
hasStripe := false
|
||||
for _, method := range payMethods {
|
||||
@@ -49,19 +49,11 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 如果启用了 Waffo 支付,添加到支付方法列表
|
||||
enableWaffo := setting.WaffoEnabled &&
|
||||
((!setting.WaffoSandbox &&
|
||||
setting.WaffoApiKey != "" &&
|
||||
setting.WaffoPrivateKey != "" &&
|
||||
setting.WaffoPublicCert != "") ||
|
||||
(setting.WaffoSandbox &&
|
||||
setting.WaffoSandboxApiKey != "" &&
|
||||
setting.WaffoSandboxPrivateKey != "" &&
|
||||
setting.WaffoSandboxPublicCert != ""))
|
||||
enableWaffo := isWaffoTopUpEnabled()
|
||||
if enableWaffo {
|
||||
hasWaffo := false
|
||||
for _, method := range payMethods {
|
||||
if method["type"] == "waffo" {
|
||||
if method["type"] == model.PaymentMethodWaffo {
|
||||
hasWaffo = true
|
||||
break
|
||||
}
|
||||
@@ -70,7 +62,7 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
if !hasWaffo {
|
||||
waffoMethod := map[string]string{
|
||||
"name": "Waffo (Global Payment)",
|
||||
"type": "waffo",
|
||||
"type": model.PaymentMethodWaffo,
|
||||
"color": "rgba(var(--semi-blue-5), 1)",
|
||||
"min_topup": strconv.Itoa(setting.WaffoMinTopUp),
|
||||
}
|
||||
@@ -78,24 +70,46 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
|
||||
if enableWaffoPancake {
|
||||
hasWaffoPancake := false
|
||||
for _, method := range payMethods {
|
||||
if method["type"] == model.PaymentMethodWaffoPancake {
|
||||
hasWaffoPancake = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWaffoPancake {
|
||||
payMethods = append(payMethods, map[string]string{
|
||||
"name": "Waffo Pancake",
|
||||
"type": model.PaymentMethodWaffoPancake,
|
||||
"color": "rgba(var(--semi-orange-5), 1)",
|
||||
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "",
|
||||
"enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "",
|
||||
"enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]",
|
||||
"enable_waffo_topup": enableWaffo,
|
||||
"enable_online_topup": isEpayTopUpEnabled(),
|
||||
"enable_stripe_topup": isStripeTopUpEnabled(),
|
||||
"enable_creem_topup": isCreemTopUpEnabled(),
|
||||
"enable_waffo_topup": enableWaffo,
|
||||
"enable_waffo_pancake_topup": enableWaffoPancake,
|
||||
"waffo_pay_methods": func() interface{} {
|
||||
if enableWaffo {
|
||||
return setting.GetWaffoPayMethods()
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
"creem_products": setting.CreemProducts,
|
||||
"pay_methods": payMethods,
|
||||
"min_topup": operation_setting.MinTopUp,
|
||||
"stripe_min_topup": setting.StripeMinTopUp,
|
||||
"waffo_min_topup": setting.WaffoMinTopUp,
|
||||
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
|
||||
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
|
||||
"creem_products": setting.CreemProducts,
|
||||
"pay_methods": payMethods,
|
||||
"min_topup": operation_setting.MinTopUp,
|
||||
"stripe_min_topup": setting.StripeMinTopUp,
|
||||
"waffo_min_topup": setting.WaffoMinTopUp,
|
||||
"waffo_pancake_min_topup": setting.WaffoPancakeMinTopUp,
|
||||
"amount_options": operation_setting.GetPaymentSetting().AmountOptions,
|
||||
"discount": operation_setting.GetPaymentSetting().AmountDiscount,
|
||||
}
|
||||
common.ApiSuccess(c, data)
|
||||
}
|
||||
@@ -109,6 +123,17 @@ type AmountRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
var nonEpayPaymentMethodsForCallback = []string{
|
||||
model.PaymentMethodStripe,
|
||||
model.PaymentMethodCreem,
|
||||
model.PaymentMethodWaffo,
|
||||
model.PaymentMethodWaffoPancake,
|
||||
}
|
||||
|
||||
func isNonEpayPaymentMethodForEpayCallback(paymentMethod string) bool {
|
||||
return lo.Contains(nonEpayPaymentMethodsForCallback, paymentMethod)
|
||||
}
|
||||
|
||||
func GetEpayClient() *epay.Client {
|
||||
if operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" {
|
||||
return nil
|
||||
@@ -167,28 +192,28 @@ func RequestEpay(c *gin.Context) {
|
||||
var req EpayRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Amount < getMinTopup() {
|
||||
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
return
|
||||
}
|
||||
payMoney := getPayMoney(req.Amount, group)
|
||||
if payMoney < 0.01 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
return
|
||||
}
|
||||
|
||||
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付方式不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -199,7 +224,7 @@ func RequestEpay(c *gin.Context) {
|
||||
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
|
||||
return
|
||||
}
|
||||
uri, params, err := client.Purchase(&epay.PurchaseArgs{
|
||||
@@ -212,7 +237,8 @@ func RequestEpay(c *gin.Context) {
|
||||
ReturnUrl: returnUrl,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 拉起支付失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
amount := req.Amount
|
||||
@@ -228,14 +254,16 @@ func RequestEpay(c *gin.Context) {
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: "pending",
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 创建充值订单失败 user_id=%d trade_no=%s payment_method=%s amount=%d error=%q", id, tradeNo, req.PaymentMethod, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "success", "data": params, "url": uri})
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值订单创建成功 user_id=%d trade_no=%s payment_method=%s amount=%d money=%.2f uri=%q params=%q", id, tradeNo, req.PaymentMethod, req.Amount, payMoney, uri, common.GetJsonString(params)))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
|
||||
}
|
||||
|
||||
// tradeNo lock
|
||||
@@ -281,12 +309,18 @@ func UnlockOrder(tradeNo string) {
|
||||
}
|
||||
|
||||
func EpayNotify(c *gin.Context) {
|
||||
if !isEpayWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
var params map[string]string
|
||||
|
||||
if c.Request.Method == "POST" {
|
||||
// POST 请求:从 POST body 解析参数
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
log.Println("易支付回调POST解析失败:", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook POST 表单解析失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
@@ -301,50 +335,63 @@ func EpayNotify(c *gin.Context) {
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 收到请求 path=%q client_ip=%s method=%s params=%q", c.Request.RequestURI, c.ClientIP(), c.Request.Method, common.GetJsonString(params)))
|
||||
|
||||
if len(params) == 0 {
|
||||
log.Println("易支付回调参数为空")
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 参数为空 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
log.Println("易支付回调失败 未找到配置信息")
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 client 未初始化 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
_, err := c.Writer.Write([]byte("fail"))
|
||||
if err != nil {
|
||||
log.Println("易支付回调写入失败")
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
}
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err == nil && verifyInfo.VerifyStatus {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签成功 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
_, err := c.Writer.Write([]byte("success"))
|
||||
if err != nil {
|
||||
log.Println("易支付回调写入失败")
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 trade_no=%s client_ip=%s error=%q", verifyInfo.ServiceTradeNo, c.ClientIP(), err.Error()))
|
||||
}
|
||||
} else {
|
||||
_, err := c.Writer.Write([]byte("fail"))
|
||||
if err != nil {
|
||||
log.Println("易支付回调写入失败")
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 webhook 响应写入失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
}
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
} else {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 webhook 验签失败 path=%q client_ip=%s verify_status=false", c.Request.RequestURI, c.ClientIP()))
|
||||
}
|
||||
log.Println("易支付回调签名验证失败")
|
||||
return
|
||||
}
|
||||
|
||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||
log.Println(verifyInfo)
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo)
|
||||
if topUp == nil {
|
||||
log.Printf("易支付回调未找到订单: %v", verifyInfo)
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 回调订单不存在 trade_no=%s callback_type=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
return
|
||||
}
|
||||
if topUp.Status == "pending" {
|
||||
topUp.Status = "success"
|
||||
if isNonEpayPaymentMethodForEpayCallback(topUp.PaymentMethod) {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
|
||||
return
|
||||
}
|
||||
if topUp.PaymentMethod != verifyInfo.Type {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("易支付 订单支付方式不匹配 trade_no=%s order_payment_method=%s callback_type=%s client_ip=%s", verifyInfo.ServiceTradeNo, topUp.PaymentMethod, verifyInfo.Type, c.ClientIP()))
|
||||
return
|
||||
}
|
||||
if topUp.Status == common.TopUpStatusPending {
|
||||
topUp.Status = common.TopUpStatusSuccess
|
||||
err := topUp.Update()
|
||||
if err != nil {
|
||||
log.Printf("易支付回调更新订单失败: %v", topUp)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新充值订单失败 trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp)))
|
||||
return
|
||||
}
|
||||
//user, _ := model.GetUserById(topUp.UserId, false)
|
||||
@@ -354,14 +401,14 @@ func EpayNotify(c *gin.Context) {
|
||||
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
|
||||
err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
|
||||
if err != nil {
|
||||
log.Printf("易支付回调更新用户失败: %v", topUp)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp)))
|
||||
return
|
||||
}
|
||||
log.Printf("易支付回调更新用户成功 %v", topUp)
|
||||
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 充值成功 trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp)))
|
||||
model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
|
||||
}
|
||||
} else {
|
||||
log.Printf("易支付异常回调: %v", verifyInfo)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("易支付 webhook 忽略事件 trade_no=%s callback_type=%s trade_status=%s client_ip=%s verify_info=%q", verifyInfo.ServiceTradeNo, verifyInfo.Type, verifyInfo.TradeStatus, c.ClientIP(), common.GetJsonString(verifyInfo)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,26 +416,26 @@ func RequestAmount(c *gin.Context) {
|
||||
var req AmountRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount < getMinTopup() {
|
||||
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())})
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
return
|
||||
}
|
||||
payMoney := getPayMoney(req.Amount, group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
}
|
||||
|
||||
func GetUserTopUps(c *gin.Context) {
|
||||
@@ -457,10 +504,9 @@ func AdminCompleteTopUp(c *gin.Context) {
|
||||
LockOrder(req.TradeNo)
|
||||
defer UnlockOrder(req.TradeNo)
|
||||
|
||||
if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
|
||||
if err := model.ManualCompleteTopUp(req.TradeNo, c.ClientIP()); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
|
||||
+63
-71
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -9,10 +10,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -20,10 +21,7 @@ import (
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentMethodCreem = "creem"
|
||||
CreemSignatureHeader = "creem-signature"
|
||||
)
|
||||
const CreemSignatureHeader = "creem-signature"
|
||||
|
||||
var creemAdaptor = &CreemAdaptor{}
|
||||
|
||||
@@ -37,9 +35,9 @@ func generateCreemSignature(payload string, secret string) string {
|
||||
// 验证Creem webhook签名
|
||||
func verifyCreemSignature(payload string, signature string, secret string) bool {
|
||||
if secret == "" {
|
||||
log.Printf("Creem webhook secret not set")
|
||||
logger.LogWarn(context.Background(), fmt.Sprintf("Creem webhook secret 未配置 test_mode=%t signature=%q body=%q", setting.CreemTestMode, signature, payload))
|
||||
if setting.CreemTestMode {
|
||||
log.Printf("Skip Creem webhook sign verify in test mode")
|
||||
logger.LogInfo(context.Background(), fmt.Sprintf("Creem webhook 验签已跳过 reason=test_mode signature=%q body=%q", signature, payload))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -66,13 +64,13 @@ type CreemAdaptor struct {
|
||||
}
|
||||
|
||||
func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
if req.PaymentMethod != PaymentMethodCreem {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||
if req.PaymentMethod != model.PaymentMethodCreem {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.ProductId == "" {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "请选择产品"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "请选择产品"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,8 +78,8 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
var products []CreemProduct
|
||||
err := json.Unmarshal([]byte(setting.CreemProducts), &products)
|
||||
if err != nil {
|
||||
log.Println("解析Creem产品列表失败", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "产品配置错误"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 产品配置解析失败 user_id=%d error=%q", c.GetInt("id"), err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品配置错误"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -95,7 +93,7 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
}
|
||||
|
||||
if selectedProduct == nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "产品不存在"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "产品不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,32 +106,32 @@ func (*CreemAdaptor) RequestPay(c *gin.Context, req *CreemPayRequest) {
|
||||
|
||||
// 先创建订单记录,使用产品配置的金额和充值额度
|
||||
topUp := &model.TopUp{
|
||||
UserId: id,
|
||||
Amount: selectedProduct.Quota, // 充值额度
|
||||
Money: selectedProduct.Price, // 支付金额
|
||||
TradeNo: referenceId,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
UserId: id,
|
||||
Amount: selectedProduct.Quota, // 充值额度
|
||||
Money: selectedProduct.Price, // 支付金额
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: model.PaymentMethodCreem,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
log.Printf("创建Creem订单失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建充值订单失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建支付链接,传入用户邮箱
|
||||
checkoutUrl, err := genCreemLink(referenceId, selectedProduct, user.Email, user.Username)
|
||||
checkoutUrl, err := genCreemLink(c.Request.Context(), referenceId, selectedProduct, user.Email, user.Username)
|
||||
if err != nil {
|
||||
log.Printf("获取Creem支付链接失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 创建支付链接失败 user_id=%d trade_no=%s product_id=%s error=%q", id, referenceId, selectedProduct.ProductId, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem订单创建成功 - 用户ID: %d, 订单号: %s, 产品: %s, 充值额度: %d, 支付金额: %.2f",
|
||||
id, referenceId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单创建成功 user_id=%d trade_no=%s product_id=%s product_name=%q quota=%d money=%.2f", id, referenceId, selectedProduct.ProductId, selectedProduct.Name, selectedProduct.Quota, selectedProduct.Price))
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": checkoutUrl,
|
||||
@@ -148,20 +146,19 @@ func RequestCreemPay(c *gin.Context) {
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("read creem pay req body err: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 支付请求读取失败 error=%q", err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
|
||||
// 打印body内容
|
||||
log.Printf("creem pay request body: %s", string(bodyBytes))
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付请求已收到 user_id=%d body=%q", c.GetInt("id"), string(bodyBytes)))
|
||||
|
||||
// 重新设置body供后续的ShouldBindJSON使用
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
err = c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
creemAdaptor.RequestPay(c, &req)
|
||||
@@ -229,35 +226,37 @@ type CreemWebhookEvent struct {
|
||||
}
|
||||
|
||||
func CreemWebhook(c *gin.Context) {
|
||||
if !isCreemWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取Creem Webhook请求body失败: %v", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取签名头
|
||||
signature := c.GetHeader(CreemSignatureHeader)
|
||||
|
||||
// 打印关键信息(避免输出完整敏感payload)
|
||||
log.Printf("Creem Webhook - URI: %s", c.Request.RequestURI)
|
||||
if setting.CreemTestMode {
|
||||
log.Printf("Creem Webhook - Signature: %s , Body: %s", signature, bodyBytes)
|
||||
} else if signature == "" {
|
||||
log.Printf("Creem Webhook缺少签名头")
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
if signature == "" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少签名 path=%q client_ip=%s body=%q", c.Request.RequestURI, c.ClientIP(), string(bodyBytes)))
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
if !verifyCreemSignature(string(bodyBytes), signature, setting.CreemWebhookSecret) {
|
||||
log.Printf("Creem Webhook签名验证失败")
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem Webhook签名验证成功")
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 验签成功 path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
|
||||
// 重新设置body供后续的ShouldBindJSON使用
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
@@ -265,19 +264,19 @@ func CreemWebhook(c *gin.Context) {
|
||||
// 解析新格式的webhook数据
|
||||
var webhookEvent CreemWebhookEvent
|
||||
if err := c.ShouldBindJSON(&webhookEvent); err != nil {
|
||||
log.Printf("解析Creem Webhook参数失败: %v", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), string(bodyBytes)))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem Webhook解析成功 - EventType: %s, EventId: %s", webhookEvent.EventType, webhookEvent.Id)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 解析成功 event_type=%s event_id=%s request_id=%s order_id=%s order_status=%s", webhookEvent.EventType, webhookEvent.Id, webhookEvent.Object.RequestId, webhookEvent.Object.Order.Id, webhookEvent.Object.Order.Status))
|
||||
|
||||
// 根据事件类型处理不同的webhook
|
||||
switch webhookEvent.EventType {
|
||||
case "checkout.completed":
|
||||
handleCheckoutCompleted(c, &webhookEvent)
|
||||
default:
|
||||
log.Printf("忽略Creem Webhook事件类型: %s", webhookEvent.EventType)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem webhook 忽略事件 event_type=%s event_id=%s", webhookEvent.EventType, webhookEvent.Id))
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -286,7 +285,7 @@ func CreemWebhook(c *gin.Context) {
|
||||
func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
// 验证订单状态
|
||||
if event.Object.Order.Status != "paid" {
|
||||
log.Printf("订单状态不是已支付: %s, 跳过处理", event.Object.Order.Status)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订单状态未支付,忽略处理 request_id=%s order_id=%s order_status=%s", event.Object.RequestId, event.Object.Order.Id, event.Object.Order.Status))
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
@@ -294,7 +293,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
// 获取引用ID(这是我们创建订单时传递的request_id)
|
||||
referenceId := event.Object.RequestId
|
||||
if referenceId == "" {
|
||||
log.Println("Creem Webhook缺少request_id字段")
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem webhook 缺少 request_id event_id=%s order_id=%s", event.Id, event.Object.Order.Id))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -302,40 +301,35 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
// Try complete subscription order first
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil {
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event), model.PaymentMethodCreem); err == nil {
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理成功 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 订阅订单处理失败 trade_no=%s creem_order_id=%s error=%q", referenceId, event.Object.Order.Id, err.Error()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款(充值)
|
||||
if event.Object.Order.Type != "onetime" {
|
||||
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 暂不支持该订单类型,忽略处理 request_id=%s creem_order_id=%s order_type=%s", referenceId, event.Object.Order.Id, event.Object.Order.Type))
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// 记录详细的支付信息
|
||||
log.Printf("处理Creem支付完成 - 订单号: %s, Creem订单ID: %s, 支付金额: %d %s, 客户邮箱: <redacted>, 产品: %s",
|
||||
referenceId,
|
||||
event.Object.Order.Id,
|
||||
event.Object.Order.AmountPaid,
|
||||
event.Object.Order.Currency,
|
||||
event.Object.Product.Name)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 支付完成回调 trade_no=%s creem_order_id=%s amount_paid=%d currency=%s product_name=%q customer_email=%q customer_name=%q", referenceId, event.Object.Order.Id, event.Object.Order.AmountPaid, event.Object.Order.Currency, event.Object.Product.Name, event.Object.Customer.Email, event.Object.Customer.Name))
|
||||
|
||||
// 查询本地订单确认存在
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
log.Printf("Creem充值订单不存在: %s", referenceId)
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 充值订单不存在 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
log.Printf("Creem充值订单状态错误: %s, 当前状态: %s", referenceId, topUp.Status)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值订单状态非 pending,忽略处理 trade_no=%s status=%s creem_order_id=%s", referenceId, topUp.Status, event.Object.Order.Id))
|
||||
c.Status(http.StatusOK) // 已处理过的订单,返回成功避免重复处理
|
||||
return
|
||||
}
|
||||
@@ -346,21 +340,20 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
|
||||
// 防护性检查,确保邮箱和姓名不为空字符串
|
||||
if customerEmail == "" {
|
||||
log.Printf("警告:Creem回调中客户邮箱为空 - 订单号: %s", referenceId)
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户邮箱为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
}
|
||||
if customerName == "" {
|
||||
log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Creem 回调客户姓名为空 trade_no=%s creem_order_id=%s", referenceId, event.Object.Order.Id))
|
||||
}
|
||||
|
||||
err := model.RechargeCreem(referenceId, customerEmail, customerName)
|
||||
err := model.RechargeCreem(referenceId, customerEmail, customerName, c.ClientIP())
|
||||
if err != nil {
|
||||
log.Printf("Creem充值处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Creem 充值处理失败 trade_no=%s creem_order_id=%s client_ip=%s error=%q", referenceId, event.Object.Order.Id, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Creem充值成功 - 订单号: %s, 充值额度: %d, 支付金额: %.2f",
|
||||
referenceId, topUp.Amount, topUp.Money)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Creem 充值成功 trade_no=%s creem_order_id=%s quota=%d money=%.2f client_ip=%s", referenceId, event.Object.Order.Id, topUp.Amount, topUp.Money, c.ClientIP()))
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -378,7 +371,7 @@ type CreemCheckoutResponse struct {
|
||||
Id string `json:"id"`
|
||||
}
|
||||
|
||||
func genCreemLink(referenceId string, product *CreemProduct, email string, username string) (string, error) {
|
||||
func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct, email string, username string) (string, error) {
|
||||
if setting.CreemApiKey == "" {
|
||||
return "", fmt.Errorf("未配置Creem API密钥")
|
||||
}
|
||||
@@ -387,7 +380,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
|
||||
apiUrl := "https://api.creem.io/v1/checkouts"
|
||||
if setting.CreemTestMode {
|
||||
apiUrl = "https://test-api.creem.io/v1/checkouts"
|
||||
log.Printf("使用Creem测试环境: %s", apiUrl)
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem 使用测试环境 api_url=%s", apiUrl))
|
||||
}
|
||||
|
||||
// 构建请求数据,确保包含用户邮箱
|
||||
@@ -423,8 +416,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-api-key", setting.CreemApiKey)
|
||||
|
||||
log.Printf("发送Creem支付请求 - URL: %s, 产品ID: %s, 用户邮箱: %s, 订单号: %s",
|
||||
apiUrl, product.ProductId, email, referenceId)
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{
|
||||
@@ -442,7 +434,7 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
|
||||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Creem API resp - status code: %d, resp: %s", resp.StatusCode, string(body))
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem API 响应已收到 trade_no=%s status_code=%d body=%q", referenceId, resp.StatusCode, string(body)))
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode/100 != 2 {
|
||||
@@ -459,6 +451,6 @@ func genCreemLink(referenceId string, product *CreemProduct, email string, usern
|
||||
return "", fmt.Errorf("Creem API resp no checkout url ")
|
||||
}
|
||||
|
||||
log.Printf("Creem 支付链接创建成功 - 订单号: %s, 支付链接: %s", referenceId, checkoutResp.CheckoutUrl)
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付链接创建成功 trade_no=%s response_id=%s checkout_url=%q", referenceId, checkoutResp.Id, checkoutResp.CheckoutUrl))
|
||||
return checkoutResp.CheckoutUrl, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
)
|
||||
|
||||
func TestIsNonEpayPaymentMethodForEpayCallback(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
paymentMethod string
|
||||
expectedBlocked bool
|
||||
}{
|
||||
{name: "stripe", paymentMethod: model.PaymentMethodStripe, expectedBlocked: true},
|
||||
{name: "creem", paymentMethod: model.PaymentMethodCreem, expectedBlocked: true},
|
||||
{name: "waffo", paymentMethod: model.PaymentMethodWaffo, expectedBlocked: true},
|
||||
{name: "waffo pancake", paymentMethod: model.PaymentMethodWaffoPancake, expectedBlocked: true},
|
||||
{name: "alipay", paymentMethod: "alipay", expectedBlocked: false},
|
||||
{name: "wxpay", paymentMethod: "wxpay", expectedBlocked: false},
|
||||
{name: "custom epay type", paymentMethod: "custom1", expectedBlocked: false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if actual := isNonEpayPaymentMethodForEpayCallback(tc.paymentMethod); actual != tc.expectedBlocked {
|
||||
t.Fatalf("expected blocked=%v, got %v for payment method %q", tc.expectedBlocked, actual, tc.paymentMethod)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+122
-52
@@ -1,16 +1,17 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -23,10 +24,6 @@ import (
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
const (
|
||||
PaymentMethodStripe = "stripe"
|
||||
)
|
||||
|
||||
var stripeAdaptor = &StripeAdaptor{}
|
||||
|
||||
// StripePayRequest represents a payment request for Stripe checkout.
|
||||
@@ -48,34 +45,34 @@ type StripeAdaptor struct {
|
||||
|
||||
func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) {
|
||||
if req.Amount < getStripeMinTopup() {
|
||||
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())})
|
||||
return
|
||||
}
|
||||
id := c.GetInt("id")
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
return
|
||||
}
|
||||
payMoney := getStripePayMoney(float64(req.Amount), group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
}
|
||||
|
||||
func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
if req.PaymentMethod != PaymentMethodStripe {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||
if req.PaymentMethod != model.PaymentMethodStripe {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付渠道"})
|
||||
return
|
||||
}
|
||||
if req.Amount < getStripeMinTopup() {
|
||||
c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10})
|
||||
return
|
||||
}
|
||||
if req.Amount > 10000 {
|
||||
c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "充值数量不能大于 10000", "data": 10})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -98,8 +95,8 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
|
||||
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
|
||||
if err != nil {
|
||||
log.Println("获取Stripe Checkout支付链接失败", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建 Checkout Session 失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -108,16 +105,18 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
Amount: req.Amount,
|
||||
Money: chargedMoney,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodStripe,
|
||||
PaymentMethod: model.PaymentMethodStripe,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Stripe 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, referenceId, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Stripe 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f", id, referenceId, req.Amount, chargedMoney))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"pay_link": payLink,
|
||||
@@ -129,7 +128,7 @@ func RequestStripeAmount(c *gin.Context) {
|
||||
var req StripePayRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
stripeAdaptor.RequestAmount(c, &req)
|
||||
@@ -139,54 +138,130 @@ func RequestStripePay(c *gin.Context) {
|
||||
var req StripePayRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
stripeAdaptor.RequestPay(c, &req)
|
||||
}
|
||||
|
||||
func StripeWebhook(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
if !isStripeWebhookEnabled() {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("解析Stripe Webhook参数失败: %v\n", err)
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
signature := c.GetHeader("Stripe-Signature")
|
||||
endpointSecret := setting.StripeWebhookSecret
|
||||
event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(payload)))
|
||||
event, err := webhook.ConstructEventWithOptions(payload, signature, setting.StripeWebhookSecret, webhook.ConstructEventOptions{
|
||||
IgnoreAPIVersionMismatch: true,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Stripe Webhook验签失败: %v\n", err)
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe webhook 验签失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
callerIp := c.ClientIP()
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 验签成功 event_type=%s client_ip=%s path=%q", string(event.Type), callerIp, c.Request.RequestURI))
|
||||
switch event.Type {
|
||||
case stripe.EventTypeCheckoutSessionCompleted:
|
||||
sessionCompleted(event)
|
||||
sessionCompleted(ctx, event, callerIp)
|
||||
case stripe.EventTypeCheckoutSessionExpired:
|
||||
sessionExpired(event)
|
||||
sessionExpired(ctx, event)
|
||||
case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
|
||||
sessionAsyncPaymentSucceeded(ctx, event, callerIp)
|
||||
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
|
||||
sessionAsyncPaymentFailed(ctx, event, callerIp)
|
||||
default:
|
||||
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe webhook 忽略事件 event_type=%s client_ip=%s", string(event.Type), callerIp))
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func sessionCompleted(event stripe.Event) {
|
||||
func sessionCompleted(ctx context.Context, event stripe.Event, callerIp string) {
|
||||
customerId := event.GetObjectValue("customer")
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
status := event.GetObjectValue("status")
|
||||
if "complete" != status {
|
||||
log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId)
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.completed 状态异常,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, status, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
paymentStatus := event.GetObjectValue("payment_status")
|
||||
if paymentStatus != "paid" {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe Checkout 支付未完成,等待异步结果 trade_no=%s payment_status=%s client_ip=%s", referenceId, paymentStatus, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
|
||||
}
|
||||
|
||||
// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
|
||||
// that confirm payment after the checkout session completes.
|
||||
func sessionAsyncPaymentSucceeded(ctx context.Context, event stripe.Event, callerIp string) {
|
||||
customerId := event.GetObjectValue("customer")
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付成功 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
|
||||
fulfillOrder(ctx, event, referenceId, customerId, callerIp)
|
||||
}
|
||||
|
||||
// sessionAsyncPaymentFailed marks orders as failed when delayed payment methods
|
||||
// ultimately fail (e.g. bank transfer not received, SEPA rejected).
|
||||
func sessionAsyncPaymentFailed(ctx context.Context, event stripe.Event, callerIp string) {
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
|
||||
if len(referenceId) == 0 {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败事件缺少订单号 client_ip=%s", callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但本地订单不存在 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != model.PaymentMethodStripe {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 异步支付失败但订单支付方式不匹配 trade_no=%s payment_method=%s client_ip=%s", referenceId, topUp.PaymentMethod, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 异步支付失败但订单状态非 pending,忽略处理 trade_no=%s status=%s client_ip=%s", referenceId, topUp.Status, callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
if err := topUp.Update(); err != nil {
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 标记充值订单失败状态失败 trade_no=%s client_ip=%s error=%q", referenceId, callerIp, err.Error()))
|
||||
return
|
||||
}
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已标记为失败 trade_no=%s client_ip=%s", referenceId, callerIp))
|
||||
}
|
||||
|
||||
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
|
||||
func fulfillOrder(ctx context.Context, event stripe.Event, referenceId string, customerId string, callerIp string) {
|
||||
if len(referenceId) == 0 {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 完成订单时缺少订单号 client_ip=%s", callerIp))
|
||||
return
|
||||
}
|
||||
|
||||
// Try complete subscription order first
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
payload := map[string]any{
|
||||
@@ -195,65 +270,60 @@ func sessionCompleted(event stripe.Event) {
|
||||
"currency": strings.ToUpper(event.GetObjectValue("currency")),
|
||||
"event_type": string(event.Type),
|
||||
}
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil {
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload), model.PaymentMethodStripe); err == nil {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单处理成功 trade_no=%s event_type=%s client_ip=%s", referenceId, string(event.Type), callerIp))
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Println("complete subscription order failed:", err.Error(), referenceId)
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
err := model.Recharge(referenceId, customerId)
|
||||
err := model.Recharge(referenceId, customerId, callerIp)
|
||||
if err != nil {
|
||||
log.Println(err.Error(), referenceId)
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 充值处理失败 trade_no=%s event_type=%s client_ip=%s error=%q", referenceId, string(event.Type), callerIp, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64)
|
||||
currency := strings.ToUpper(event.GetObjectValue("currency"))
|
||||
log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency)
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值成功 trade_no=%s amount_total=%.2f currency=%s event_type=%s client_ip=%s", referenceId, total/100, currency, string(event.Type), callerIp))
|
||||
}
|
||||
|
||||
func sessionExpired(event stripe.Event) {
|
||||
func sessionExpired(ctx context.Context, event stripe.Event) {
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
status := event.GetObjectValue("status")
|
||||
if "expired" != status {
|
||||
log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId)
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe checkout.expired 状态异常,忽略处理 trade_no=%s status=%s", referenceId, status))
|
||||
return
|
||||
}
|
||||
|
||||
if len(referenceId) == 0 {
|
||||
log.Println("未提供支付单号")
|
||||
logger.LogWarn(ctx, "Stripe checkout.expired 缺少订单号")
|
||||
return
|
||||
}
|
||||
|
||||
// Subscription order expiration
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.ExpireSubscriptionOrder(referenceId); err == nil {
|
||||
if err := model.ExpireSubscriptionOrder(referenceId, model.PaymentMethodStripe); err == nil {
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 订阅订单已过期 trade_no=%s", referenceId))
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 订阅订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
log.Println("充值订单不存在", referenceId)
|
||||
err := model.UpdatePendingTopUpStatus(referenceId, model.PaymentMethodStripe, common.TopUpStatusExpired)
|
||||
if errors.Is(err, model.ErrTopUpNotFound) {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("Stripe 充值订单不存在,无法标记过期 trade_no=%s", referenceId))
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
log.Println("充值订单状态错误", referenceId)
|
||||
}
|
||||
|
||||
topUp.Status = common.TopUpStatusExpired
|
||||
err := topUp.Update()
|
||||
if err != nil {
|
||||
log.Println("过期充值订单失败", referenceId, ", err:", err.Error())
|
||||
logger.LogError(ctx, fmt.Sprintf("Stripe 充值订单过期处理失败 trade_no=%s error=%q", referenceId, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("充值订单已过期", referenceId)
|
||||
logger.LogInfo(ctx, fmt.Sprintf("Stripe 充值订单已过期 trade_no=%s", referenceId))
|
||||
}
|
||||
|
||||
// genStripeLink generates a Stripe Checkout session URL for payment.
|
||||
|
||||
+73
-36
@@ -1,14 +1,15 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
@@ -99,28 +100,57 @@ type WaffoPayRequest struct {
|
||||
PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index
|
||||
}
|
||||
|
||||
func RequestWaffoAmount(c *gin.Context) {
|
||||
var req WaffoPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
waffoMinTopup := int64(setting.WaffoMinTopUp)
|
||||
if req.Amount < waffoMinTopup {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
return
|
||||
}
|
||||
|
||||
payMoney := getWaffoPayMoney(float64(req.Amount), group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
|
||||
}
|
||||
|
||||
// RequestWaffoPay 创建 Waffo 支付订单
|
||||
func RequestWaffoPay(c *gin.Context) {
|
||||
if !setting.WaffoEnabled {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "Waffo 支付未启用"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo 支付未启用"})
|
||||
return
|
||||
}
|
||||
|
||||
var req WaffoPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
waffoMinTopup := int64(setting.WaffoMinTopUp)
|
||||
if req.Amount < waffoMinTopup {
|
||||
c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "用户不存在"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -131,8 +161,8 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
// 新协议:按索引查找
|
||||
idx := *req.PayMethodIndex
|
||||
if idx < 0 || idx >= len(methods) {
|
||||
log.Printf("Waffo 无效的支付方式索引: %d, UserId=%d, 可用范围: [0, %d)", idx, id, len(methods))
|
||||
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式索引无效 user_id=%d pay_method_index=%d method_count=%d", id, idx, len(methods)))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
|
||||
return
|
||||
}
|
||||
resolvedPayMethodType = methods[idx].PayMethodType
|
||||
@@ -149,8 +179,8 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"})
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 支付方式无效 user_id=%d pay_method_type=%s pay_method_name=%q", id, req.PayMethodType, req.PayMethodName))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -159,7 +189,7 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
group, _ := model.GetUserGroup(id, true)
|
||||
payMoney := getWaffoPayMoney(float64(req.Amount), group)
|
||||
if payMoney < 0.01 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -182,22 +212,22 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
Amount: amount,
|
||||
Money: payMoney,
|
||||
TradeNo: merchantOrderId,
|
||||
PaymentMethod: "waffo",
|
||||
PaymentMethod: model.PaymentMethodWaffo,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := topUp.Insert(); err != nil {
|
||||
log.Printf("Waffo 创建本地订单失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, merchantOrderId, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
sdk, err := getWaffoSDK()
|
||||
if err != nil {
|
||||
log.Printf("Waffo SDK 初始化失败: %v", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo SDK 初始化失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "支付配置错误"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -238,29 +268,29 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
}
|
||||
resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil)
|
||||
if err != nil {
|
||||
log.Printf("Waffo 创建订单失败: %v", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 创建订单失败 user_id=%d trade_no=%s error=%q", id, merchantOrderId, err.Error()))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
if !resp.IsSuccess() {
|
||||
log.Printf("Waffo 创建订单业务失败: [%s] %s, 完整响应: %+v", resp.Code, resp.Message, resp)
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo 创建订单业务失败 user_id=%d trade_no=%s code=%s message=%q response=%q", id, merchantOrderId, resp.Code, resp.Message, common.GetJsonString(resp)))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
orderData := resp.GetData()
|
||||
log.Printf("Waffo 订单创建成功 - 用户: %d, 订单: %s, 金额: %.2f", id, merchantOrderId, payMoney)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值订单创建成功 user_id=%d trade_no=%s amount=%d money=%.2f pay_method_type=%s pay_method_name=%q", id, merchantOrderId, req.Amount, payMoney, resolvedPayMethodType, resolvedPayMethodName))
|
||||
|
||||
paymentUrl := orderData.FetchRedirectURL()
|
||||
if paymentUrl == "" {
|
||||
paymentUrl = orderData.OrderAction
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"payment_url": paymentUrl,
|
||||
@@ -287,16 +317,22 @@ type webhookSubscriptionInfo struct {
|
||||
|
||||
// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅)
|
||||
func WaffoWebhook(c *gin.Context) {
|
||||
if !isWaffoWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("Waffo Webhook 读取 body 失败: %v", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
sdk, err := getWaffoSDK()
|
||||
if err != nil {
|
||||
log.Printf("Waffo Webhook SDK 初始化失败: %v", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook SDK 初始化失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -304,17 +340,18 @@ func WaffoWebhook(c *gin.Context) {
|
||||
wh := sdk.Webhook()
|
||||
bodyStr := string(bodyBytes)
|
||||
signature := c.GetHeader("X-SIGNATURE")
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
|
||||
|
||||
// 验证请求签名
|
||||
if !wh.VerifySignature(bodyStr, signature) {
|
||||
log.Printf("Waffo webhook 签名验证失败")
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签失败 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, bodyStr))
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var event core.WebhookEvent
|
||||
if err := common.Unmarshal(bodyBytes, &event); err != nil {
|
||||
log.Printf("Waffo Webhook 解析失败: %v", err)
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo webhook 解析失败 path=%q client_ip=%s error=%q body=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), bodyStr))
|
||||
sendWaffoWebhookResponse(c, wh, false, "invalid payload")
|
||||
return
|
||||
}
|
||||
@@ -324,14 +361,14 @@ func WaffoWebhook(c *gin.Context) {
|
||||
// 解析为扩展类型,区分普通支付和订阅支付
|
||||
var payload webhookPayloadWithSubInfo
|
||||
if err := common.Unmarshal(bodyBytes, &payload); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 支付回调载荷解析失败 event_type=%s client_ip=%s error=%q body=%q", event.EventType, c.ClientIP(), err.Error(), bodyStr))
|
||||
sendWaffoWebhookResponse(c, wh, false, "invalid payment payload")
|
||||
return
|
||||
}
|
||||
log.Printf("Waffo Webhook - EventType: %s, MerchantOrderId: %s, OrderStatus: %s",
|
||||
event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 验签并解析成功 event_type=%s merchant_order_id=%s order_status=%s client_ip=%s", event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus, c.ClientIP()))
|
||||
handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult)
|
||||
default:
|
||||
log.Printf("Waffo Webhook 未知事件: %s", event.EventType)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo webhook 忽略事件 event_type=%s client_ip=%s", event.EventType, c.ClientIP()))
|
||||
sendWaffoWebhookResponse(c, wh, true, "")
|
||||
}
|
||||
}
|
||||
@@ -339,13 +376,13 @@ func WaffoWebhook(c *gin.Context) {
|
||||
// handleWaffoPayment 处理支付完成通知
|
||||
func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) {
|
||||
if result.OrderStatus != "PAY_SUCCESS" {
|
||||
log.Printf("Waffo 订单状态非成功: %s, 订单: %s", result.OrderStatus, result.MerchantOrderID)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 订单状态非成功,忽略充值 trade_no=%s order_status=%s client_ip=%s", result.MerchantOrderID, result.OrderStatus, c.ClientIP()))
|
||||
// 终态失败订单标记为 failed,避免永远停在 pending
|
||||
if result.MerchantOrderID != "" {
|
||||
if topUp := model.GetTopUpByTradeNo(result.MerchantOrderID); topUp != nil &&
|
||||
topUp.Status == common.TopUpStatusPending {
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
if err := model.UpdatePendingTopUpStatus(result.MerchantOrderID, model.PaymentMethodWaffo, common.TopUpStatusFailed); err != nil &&
|
||||
!errors.Is(err, model.ErrTopUpNotFound) &&
|
||||
!errors.Is(err, model.ErrTopUpStatusInvalid) {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 标记失败订单状态失败 trade_no=%s error=%q", result.MerchantOrderID, err.Error()))
|
||||
}
|
||||
}
|
||||
sendWaffoWebhookResponse(c, wh, true, "")
|
||||
@@ -357,13 +394,13 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
|
||||
LockOrder(merchantOrderId)
|
||||
defer UnlockOrder(merchantOrderId)
|
||||
|
||||
if err := model.RechargeWaffo(merchantOrderId); err != nil {
|
||||
log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
|
||||
if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo 充值处理失败 trade_no=%s client_ip=%s error=%q", merchantOrderId, c.ClientIP(), err.Error()))
|
||||
sendWaffoWebhookResponse(c, wh, false, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Waffo 充值成功 - 订单: %s", merchantOrderId)
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo 充值成功 trade_no=%s client_ip=%s", merchantOrderId, c.ClientIP()))
|
||||
sendWaffoWebhookResponse(c, wh, true, "")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type WaffoPancakePayRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
func RequestWaffoPancakeAmount(c *gin.Context) {
|
||||
var req WaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
return
|
||||
}
|
||||
|
||||
payMoney := getWaffoPancakePayMoney(req.Amount, group)
|
||||
if payMoney <= 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": fmt.Sprintf("%.2f", payMoney)})
|
||||
}
|
||||
|
||||
func getWaffoPancakePayMoney(amount int64, group string) float64 {
|
||||
dAmount := decimal.NewFromInt(amount)
|
||||
if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens {
|
||||
dAmount = dAmount.Div(decimal.NewFromFloat(common.QuotaPerUnit))
|
||||
}
|
||||
|
||||
topupGroupRatio := common.GetTopupGroupRatio(group)
|
||||
if topupGroupRatio == 0 {
|
||||
topupGroupRatio = 1
|
||||
}
|
||||
|
||||
discount := 1.0
|
||||
if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok && ds > 0 {
|
||||
discount = ds
|
||||
}
|
||||
|
||||
payMoney := dAmount.
|
||||
Mul(decimal.NewFromFloat(setting.WaffoPancakeUnitPrice)).
|
||||
Mul(decimal.NewFromFloat(topupGroupRatio)).
|
||||
Mul(decimal.NewFromFloat(discount))
|
||||
|
||||
return payMoney.InexactFloat64()
|
||||
}
|
||||
|
||||
func normalizeWaffoPancakeTopUpAmount(amount int64) int64 {
|
||||
if operation_setting.GetQuotaDisplayType() != operation_setting.QuotaDisplayTypeTokens {
|
||||
return amount
|
||||
}
|
||||
|
||||
normalized := decimal.NewFromInt(amount).
|
||||
Div(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
IntPart()
|
||||
if normalized < 1 {
|
||||
return 1
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func formatWaffoPancakeAmount(payMoney float64) string {
|
||||
return decimal.NewFromFloat(payMoney).StringFixed(2)
|
||||
}
|
||||
|
||||
func getWaffoPancakeBuyerEmail(user *model.User) string {
|
||||
if user != nil && strings.TrimSpace(user.Email) != "" {
|
||||
return user.Email
|
||||
}
|
||||
if user != nil {
|
||||
return fmt.Sprintf("%d@new-api.local", user.Id)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getWaffoPancakeReturnURL() string {
|
||||
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
|
||||
return setting.WaffoPancakeReturnURL
|
||||
}
|
||||
return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true"
|
||||
}
|
||||
|
||||
func RequestWaffoPancakePay(c *gin.Context) {
|
||||
if !setting.WaffoPancakeEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"})
|
||||
return
|
||||
}
|
||||
currentWebhookKey := setting.WaffoPancakeWebhookPublicKey
|
||||
if setting.WaffoPancakeSandbox {
|
||||
currentWebhookKey = setting.WaffoPancakeWebhookTestKey
|
||||
}
|
||||
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
|
||||
strings.TrimSpace(currentWebhookKey) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakeStoreID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakeProductID) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
|
||||
return
|
||||
}
|
||||
|
||||
var req WaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
if req.Amount < int64(setting.WaffoPancakeMinTopUp) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", setting.WaffoPancakeMinTopUp)})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, false)
|
||||
if err != nil || user == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
group, err := model.GetUserGroup(id, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"})
|
||||
return
|
||||
}
|
||||
|
||||
payMoney := getWaffoPancakePayMoney(req.Amount, group)
|
||||
if payMoney < 0.01 {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"})
|
||||
return
|
||||
}
|
||||
|
||||
tradeNo := fmt.Sprintf("WAFFO_PANCAKE-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6))
|
||||
topUp := &model.TopUp{
|
||||
UserId: id,
|
||||
Amount: normalizeWaffoPancakeTopUpAmount(req.Amount),
|
||||
Money: payMoney,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := topUp.Insert(); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建充值订单失败 user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresInSeconds := 45 * 60
|
||||
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
|
||||
StoreID: setting.WaffoPancakeStoreID,
|
||||
ProductID: setting.WaffoPancakeProductID,
|
||||
ProductType: "onetime",
|
||||
Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
|
||||
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
|
||||
Amount: formatWaffoPancakeAmount(payMoney),
|
||||
TaxIncluded: false,
|
||||
TaxCategory: "saas",
|
||||
},
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
SuccessURL: getWaffoPancakeReturnURL(),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
|
||||
topUp.Status = common.TopUpStatusFailed
|
||||
_ = topUp.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值订单创建成功 user_id=%d trade_no=%s session_id=%s amount=%d money=%.2f", id, tradeNo, session.SessionID, req.Amount, payMoney))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": session.CheckoutURL,
|
||||
"session_id": session.SessionID,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"order_id": tradeNo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func WaffoPancakeWebhook(c *gin.Context) {
|
||||
if !isWaffoPancakeWebhookEnabled() {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 被拒绝 reason=webhook_disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP()))
|
||||
c.String(http.StatusForbidden, "webhook disabled")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
c.String(http.StatusBadRequest, "bad request")
|
||||
return
|
||||
}
|
||||
|
||||
signature := c.GetHeader("X-Waffo-Signature")
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 收到请求 path=%q client_ip=%s signature=%q body=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes)))
|
||||
|
||||
event, err := service.VerifyConfiguredWaffoPancakeWebhook(string(bodyBytes), signature)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签失败 path=%q client_ip=%s signature=%q body=%q error=%q", c.Request.RequestURI, c.ClientIP(), signature, string(bodyBytes), err.Error()))
|
||||
c.String(http.StatusUnauthorized, "invalid signature")
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
if event.NormalizedEventType() != "order.completed" {
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error()))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
LockOrder(tradeNo)
|
||||
defer UnlockOrder(tradeNo)
|
||||
|
||||
if err := model.RechargeWaffoPancake(tradeNo); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值处理失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
||||
c.String(http.StatusInternalServerError, "retry")
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 充值成功 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
c.String(http.StatusOK, "OK")
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFormatWaffoPancakeAmount_UsesDisplayPriceString(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
amount float64
|
||||
expected string
|
||||
}{
|
||||
{name: "whole amount", amount: 29, expected: "29.00"},
|
||||
{name: "decimal amount", amount: 29.9, expected: "29.90"},
|
||||
{name: "round half up to cents", amount: 29.999, expected: "30.00"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.expected, formatWaffoPancakeAmount(tc.amount))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWaffoPancakePayMoney(t *testing.T) {
|
||||
originalUnitPrice := setting.WaffoPancakeUnitPrice
|
||||
originalQuotaDisplayType := operation_setting.GetGeneralSetting().QuotaDisplayType
|
||||
originalDiscounts := make(map[int]float64, len(operation_setting.GetPaymentSetting().AmountDiscount))
|
||||
for k, v := range operation_setting.GetPaymentSetting().AmountDiscount {
|
||||
originalDiscounts[k] = v
|
||||
}
|
||||
originalTopupGroupRatio := common.TopupGroupRatio2JSONString()
|
||||
|
||||
t.Cleanup(func() {
|
||||
setting.WaffoPancakeUnitPrice = originalUnitPrice
|
||||
operation_setting.GetGeneralSetting().QuotaDisplayType = originalQuotaDisplayType
|
||||
operation_setting.GetPaymentSetting().AmountDiscount = originalDiscounts
|
||||
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(originalTopupGroupRatio))
|
||||
})
|
||||
|
||||
setting.WaffoPancakeUnitPrice = 2.5
|
||||
operation_setting.GetPaymentSetting().AmountDiscount = map[int]float64{
|
||||
10: 0.8,
|
||||
int(common.QuotaPerUnit * 3): 0.5,
|
||||
20: 0,
|
||||
}
|
||||
require.NoError(t, common.UpdateTopupGroupRatioByJSONString(`{"default":1,"vip":1.2}`))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
amount int64
|
||||
group string
|
||||
quotaDisplayType string
|
||||
expected float64
|
||||
}{
|
||||
{
|
||||
name: "currency display applies unit price group ratio and discount",
|
||||
amount: 10,
|
||||
group: "vip",
|
||||
quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
|
||||
expected: 24,
|
||||
},
|
||||
{
|
||||
name: "tokens display converts quota to display units before pricing",
|
||||
amount: int64(common.QuotaPerUnit * 3),
|
||||
group: "vip",
|
||||
quotaDisplayType: operation_setting.QuotaDisplayTypeTokens,
|
||||
expected: 4.5,
|
||||
},
|
||||
{
|
||||
name: "non-positive discount falls back to no discount",
|
||||
amount: 20,
|
||||
group: "default",
|
||||
quotaDisplayType: operation_setting.QuotaDisplayTypeUSD,
|
||||
expected: 50,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
operation_setting.GetGeneralSetting().QuotaDisplayType = tc.quotaDisplayType
|
||||
actual := getWaffoPancakePayMoney(tc.amount, tc.group)
|
||||
require.InDelta(t, tc.expected, actual, 0.000001)
|
||||
})
|
||||
}
|
||||
}
|
||||
+8
-4
@@ -2,7 +2,6 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -542,10 +541,15 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
// 记录操作日志:管理员身份通过 admin_info 传递,避免在非管理员可见的日志内容中泄露。
|
||||
adminId := c.GetInt("id")
|
||||
model.RecordLog(userId, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId))
|
||||
adminName := c.GetString("username")
|
||||
adminInfo := map[string]interface{}{
|
||||
"admin_id": adminId,
|
||||
"admin_username": adminName,
|
||||
}
|
||||
model.RecordLogWithAdminInfo(userId, model.LogTypeManage,
|
||||
"管理员强制禁用了用户的两步验证", adminInfo)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
|
||||
@@ -27,6 +27,21 @@ func GetAllQuotaDates(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func GetQuotaDatesByUser(c *gin.Context) {
|
||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
|
||||
dates, err := model.GetQuotaDataGroupByUser(startTimestamp, endTimestamp)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": dates,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUserQuotaDates(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
|
||||
|
||||
+75
-7
@@ -52,10 +52,15 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
err = user.ValidateAndFill()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": err.Error(),
|
||||
"success": false,
|
||||
})
|
||||
switch {
|
||||
case errors.Is(err, model.ErrDatabase):
|
||||
common.SysLog(fmt.Sprintf("Login database error for user %s: %v", username, err))
|
||||
common.ApiErrorI18n(c, i18n.MsgDatabaseError)
|
||||
case errors.Is(err, model.ErrUserEmptyCredentials):
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
default:
|
||||
common.ApiErrorI18n(c, i18n.MsgUserUsernameOrPasswordError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,9 +577,6 @@ func UpdateUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if originUser.Quota != updatedUser.Quota {
|
||||
model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota)))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -841,6 +843,8 @@ func CreateUser(c *gin.Context) {
|
||||
type ManageRequest struct {
|
||||
Id int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Value int `json:"value"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
// ManageUser Only admin user can do this
|
||||
@@ -887,6 +891,11 @@ func ManageUser(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// 删除用户后,强制清理 Redis 中所有该用户令牌的缓存,
|
||||
// 避免已缓存的令牌在 TTL 过期前仍能通过 TokenAuth 校验。
|
||||
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
case "promote":
|
||||
if myRole != common.RoleRootUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
|
||||
@@ -907,12 +916,71 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
user.Role = common.RoleCommonUser
|
||||
case "add_quota":
|
||||
adminName := c.GetString("username")
|
||||
adminId := c.GetInt("id")
|
||||
adminInfo := map[string]interface{}{
|
||||
"admin_id": adminId,
|
||||
"admin_username": adminName,
|
||||
}
|
||||
switch req.Mode {
|
||||
case "add":
|
||||
if req.Value <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
return
|
||||
}
|
||||
if err := model.IncreaseUserQuota(user.Id, req.Value, true); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员增加用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
|
||||
case "subtract":
|
||||
if req.Value <= 0 {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserQuotaChangeZero)
|
||||
return
|
||||
}
|
||||
if err := model.DecreaseUserQuota(user.Id, req.Value, true); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员减少用户额度 %s", logger.LogQuota(req.Value)), adminInfo)
|
||||
case "override":
|
||||
oldQuota := user.Quota
|
||||
if err := model.DB.Model(&model.User{}).Where("id = ?", user.Id).Update("quota", req.Value).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLogWithAdminInfo(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员覆盖用户额度从 %s 为 %s", logger.LogQuota(oldQuota), logger.LogQuota(req.Value)), adminInfo)
|
||||
default:
|
||||
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// 禁用 / 角色调整后,强制失效用户缓存与其全部令牌缓存,
|
||||
// 避免在 Redis TTL 过期前仍使用旧状态(尤其是禁用后仍可发起请求的问题)。
|
||||
// InvalidateUserCache 会让下一次 GetUserCache 从数据库重新加载,
|
||||
// InvalidateUserTokensCache 则确保令牌侧的缓存也同步刷新。
|
||||
if req.Action == "disable" || req.Action == "promote" || req.Action == "demote" {
|
||||
if err := model.InvalidateUserCache(user.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate user cache for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
}
|
||||
clearUser := model.User{
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
|
||||
+3
-1
@@ -28,10 +28,11 @@ services:
|
||||
environment:
|
||||
- SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production!
|
||||
# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL
|
||||
- REDIS_CONN_STRING=redis://redis
|
||||
- REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production!
|
||||
- TZ=Asia/Shanghai
|
||||
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
|
||||
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
|
||||
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
|
||||
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
|
||||
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
|
||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||
@@ -55,6 +56,7 @@ services:
|
||||
image: redis:latest
|
||||
container_name: redis
|
||||
restart: always
|
||||
command: ["redis-server", "--requirepass", "123456"] # ⚠️ IMPORTANT: Change this password in production!
|
||||
networks:
|
||||
- new-api-network
|
||||
|
||||
|
||||
+53
-1
@@ -3281,6 +3281,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"cache_control": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"inference_geo": {
|
||||
"type": "string"
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
@@ -3333,7 +3340,8 @@
|
||||
"enum": [
|
||||
"auto",
|
||||
"any",
|
||||
"tool"
|
||||
"tool",
|
||||
"none"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
@@ -3358,6 +3366,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"context_management": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"output_config": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"output_format": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"container": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"mcp_servers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -3365,6 +3403,20 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"speed": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"standard",
|
||||
"fast"
|
||||
]
|
||||
},
|
||||
"service_tier": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"standard_only"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,6 +18,16 @@ type AudioRequest struct {
|
||||
Speed *float64 `json:"speed,omitempty"`
|
||||
StreamFormat string `json:"stream_format,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
// vllm-omini
|
||||
TaskType json.RawMessage `json:"task_type,omitempty"`
|
||||
Language json.RawMessage `json:"language,omitempty"`
|
||||
RefAudio json.RawMessage `json:"ref_audio,omitempty"`
|
||||
RefText json.RawMessage `json:"ref_text,omitempty"`
|
||||
XVectorOnlyMode json.RawMessage `json:"x_vector_only_mode,omitempty"`
|
||||
MaxNewTokens json.RawMessage `json:"max_new_tokens,omitempty"`
|
||||
InitialCodecChunkFrames json.RawMessage `json:"initial_codec_chunk_frames,omitempty"`
|
||||
// TODO:ensure that the logic remains correct after the stream is started.
|
||||
//Stream json.RawMessage `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
|
||||
@@ -30,6 +30,7 @@ type ChannelOtherSettings struct {
|
||||
ClaudeBetaQuery bool `json:"claude_beta_query,omitempty"` // Claude 渠道是否强制追加 ?beta=true
|
||||
AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费)
|
||||
AllowInferenceGeo bool `json:"allow_inference_geo,omitempty"` // 是否允许 inference_geo 透传(仅 Claude,默认过滤以满足数据驻留合规
|
||||
AllowSpeed bool `json:"allow_speed,omitempty"` // 是否允许 speed 透传(仅 Claude,默认过滤以避免意外切换推理速度模式)
|
||||
AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私)
|
||||
DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
AllowIncludeObfuscation bool `json:"allow_include_obfuscation,omitempty"` // 是否允许 stream_options.include_obfuscation 透传(默认过滤以避免关闭流混淆保护)
|
||||
|
||||
+37
-34
@@ -98,6 +98,20 @@ func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage {
|
||||
return mediaContent
|
||||
}
|
||||
|
||||
func (m *ClaudeMediaMessage) ToFileSource() types.FileSource {
|
||||
if m.Source == nil {
|
||||
return nil
|
||||
}
|
||||
data := m.Source.Url
|
||||
if data == "" {
|
||||
data = common.Interface2String(m.Source.Data)
|
||||
}
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(data, m.Source.MediaType)
|
||||
}
|
||||
|
||||
type ClaudeMessageSource struct {
|
||||
Type string `json:"type"`
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
@@ -190,10 +204,11 @@ type ClaudeToolChoice struct {
|
||||
}
|
||||
|
||||
type ClaudeRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []ClaudeMessage `json:"messages,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
System any `json:"system,omitempty"`
|
||||
Messages []ClaudeMessage `json:"messages,omitempty"`
|
||||
CacheControl json.RawMessage `json:"cache_control,omitempty"`
|
||||
// InferenceGeo controls Claude data residency region.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_inference_geo.
|
||||
InferenceGeo string `json:"inference_geo,omitempty"`
|
||||
@@ -213,6 +228,9 @@ type ClaudeRequest struct {
|
||||
Thinking *Thinking `json:"thinking,omitempty"`
|
||||
McpServers json.RawMessage `json:"mcp_servers,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
// Speed specifies the Claude inference speed mode.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_speed.
|
||||
Speed json.RawMessage `json:"speed,omitempty"`
|
||||
// ServiceTier specifies upstream service level and may affect billing.
|
||||
// This field is filtered by default and can be enabled via channel setting allow_service_tier.
|
||||
ServiceTier string `json:"service_tier,omitempty"`
|
||||
@@ -223,14 +241,6 @@ type OutputConfigForEffort struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createClaudeFileSource(data string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, "")
|
||||
}
|
||||
|
||||
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
maxTokens := 0
|
||||
if c.MaxTokens != nil {
|
||||
@@ -258,17 +268,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
case "text":
|
||||
texts = append(texts, media.GetText())
|
||||
case "image":
|
||||
if media.Source != nil {
|
||||
data := media.Source.Url
|
||||
if data == "" {
|
||||
data = common.Interface2String(media.Source.Data)
|
||||
}
|
||||
if data != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
if source := media.ToFileSource(); source != nil {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,17 +297,11 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
case "text":
|
||||
texts = append(texts, media.GetText())
|
||||
case "image":
|
||||
if media.Source != nil {
|
||||
data := media.Source.Url
|
||||
if data == "" {
|
||||
data = common.Interface2String(media.Source.Data)
|
||||
}
|
||||
if data != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createClaudeFileSource(data),
|
||||
})
|
||||
}
|
||||
if source := media.ToFileSource(); source != nil {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
case "tool_use":
|
||||
if media.Name != "" {
|
||||
@@ -450,6 +448,11 @@ func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) {
|
||||
type Thinking struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
BudgetTokens *int `json:"budget_tokens,omitempty"`
|
||||
// Display controls whether thinking content is returned in the response.
|
||||
// Used with adaptive thinking on Claude Opus 4.7+: "summarized" restores
|
||||
// the visible summary that was default on Opus 4.6; "omitted" (default on
|
||||
// 4.7) suppresses it. Pass-through field from upstream Anthropic API.
|
||||
Display string `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Thinking) GetBudgetTokens() int {
|
||||
|
||||
+14
-11
@@ -46,6 +46,7 @@ func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
|
||||
type ToolConfig struct {
|
||||
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
|
||||
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
|
||||
IncludeServerSideToolInvocations *bool `json:"includeServerSideToolInvocations,omitempty"`
|
||||
}
|
||||
|
||||
type FunctionCallingConfig struct {
|
||||
@@ -64,14 +65,6 @@ type LatLng struct {
|
||||
Longitude *float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, mimeType)
|
||||
}
|
||||
|
||||
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var files []*types.FileMeta = make([]*types.FileMeta, 0)
|
||||
|
||||
@@ -87,9 +80,8 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
if part.Text != "" {
|
||||
inputTexts = append(inputTexts, part.Text)
|
||||
}
|
||||
if part.InlineData != nil && part.InlineData.Data != "" {
|
||||
if source := part.InlineData.ToFileSource(); source != nil {
|
||||
mimeType := part.InlineData.MimeType
|
||||
source := createGeminiFileSource(part.InlineData.Data, mimeType)
|
||||
var fileType types.FileType
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
fileType = types.FileTypeImage
|
||||
@@ -103,7 +95,6 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
files = append(files, &types.FileMeta{
|
||||
FileType: fileType,
|
||||
Source: source,
|
||||
MimeType: mimeType,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -121,6 +112,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
|
||||
if c.Query("alt") == "sse" {
|
||||
return true
|
||||
}
|
||||
// Native Gemini API uses URL action to indicate streaming:
|
||||
// /v1beta/models/{model}:streamGenerateContent
|
||||
if strings.Contains(c.Request.URL.Path, "streamGenerateContent") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -210,6 +206,13 @@ type GeminiInlineData struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
func (d *GeminiInlineData) ToFileSource() types.FileSource {
|
||||
if d == nil || d.Data == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(d.Data, d.MimeType)
|
||||
}
|
||||
|
||||
// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType
|
||||
func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
|
||||
type Alias GeminiInlineData // Use type alias to avoid recursion
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGeminiChatRequest_IsStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
query string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "streamGenerateContent without alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
|
||||
query: "key=sk-xxx",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "streamGenerateContent with alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:streamGenerateContent",
|
||||
query: "alt=sse&key=sk-xxx",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "generateContent without alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:generateContent",
|
||||
query: "key=sk-xxx",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "generateContent with alt=sse",
|
||||
path: "/v1beta/models/gemini-2.0-flash:generateContent",
|
||||
query: "alt=sse",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "GenerateContent capitalized",
|
||||
path: "/v1beta/models/gemini-2.0-flash:GenerateContent",
|
||||
query: "key=sk-xxx",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "embedding path",
|
||||
path: "/v1beta/models/gemini-2.0-flash:embedContent",
|
||||
query: "",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
url := tt.path
|
||||
if tt.query != "" {
|
||||
url += "?" + tt.query
|
||||
}
|
||||
c.Request, _ = http.NewRequest("POST", url, nil)
|
||||
|
||||
req := &GeminiChatRequest{}
|
||||
assert.Equal(t, tt.expected, req.IsStream(c))
|
||||
})
|
||||
}
|
||||
}
|
||||
+53
-47
@@ -108,14 +108,6 @@ type GeneralOpenAIRequest struct {
|
||||
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
|
||||
}
|
||||
|
||||
// createFileSource 根据数据内容创建正确类型的 FileSource
|
||||
func createFileSource(data string) *types.FileSource {
|
||||
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
|
||||
return types.NewURLFileSource(data)
|
||||
}
|
||||
return types.NewBase64FileSource(data, "")
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
var tokenCountMeta types.TokenCountMeta
|
||||
var texts = make([]string, 0)
|
||||
@@ -159,44 +151,24 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
arrayContent := message.ParseContent()
|
||||
for _, m := range arrayContent {
|
||||
if m.Type == ContentTypeImageURL {
|
||||
imageUrl := m.GetImageMedia()
|
||||
if imageUrl != nil && imageUrl.Url != "" {
|
||||
source := createFileSource(imageUrl.Url)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: source,
|
||||
Detail: imageUrl.Detail,
|
||||
})
|
||||
source := m.ToFileSource()
|
||||
if source != nil {
|
||||
meta := &types.FileMeta{Source: source}
|
||||
switch m.Type {
|
||||
case ContentTypeImageURL:
|
||||
meta.FileType = types.FileTypeImage
|
||||
if img := m.GetImageMedia(); img != nil {
|
||||
meta.Detail = img.Detail
|
||||
}
|
||||
case ContentTypeInputAudio:
|
||||
meta.FileType = types.FileTypeAudio
|
||||
case ContentTypeFile:
|
||||
meta.FileType = types.FileTypeFile
|
||||
case ContentTypeVideoUrl:
|
||||
meta.FileType = types.FileTypeVideo
|
||||
}
|
||||
} else if m.Type == ContentTypeInputAudio {
|
||||
inputAudio := m.GetInputAudio()
|
||||
if inputAudio != nil && inputAudio.Data != "" {
|
||||
source := createFileSource(inputAudio.Data)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeAudio,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else if m.Type == ContentTypeFile {
|
||||
file := m.GetFile()
|
||||
if file != nil && file.FileData != "" {
|
||||
source := createFileSource(file.FileData)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeFile,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else if m.Type == ContentTypeVideoUrl {
|
||||
videoUrl := m.GetVideoUrl()
|
||||
if videoUrl != nil && videoUrl.Url != "" {
|
||||
source := createFileSource(videoUrl.Url)
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeVideo,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fileMeta = append(fileMeta, meta)
|
||||
} else if m.Type == ContentTypeText {
|
||||
texts = append(texts, m.Text)
|
||||
}
|
||||
}
|
||||
@@ -391,6 +363,40 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MediaContent) ToFileSource() types.FileSource {
|
||||
switch m.Type {
|
||||
case ContentTypeImageURL:
|
||||
img := m.GetImageMedia()
|
||||
if img == nil || img.Url == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(img.Url, img.MimeType)
|
||||
case ContentTypeInputAudio:
|
||||
audio := m.GetInputAudio()
|
||||
if audio == nil || audio.Data == "" {
|
||||
return nil
|
||||
}
|
||||
mimeType := ""
|
||||
if audio.Format != "" {
|
||||
mimeType = "audio/" + audio.Format
|
||||
}
|
||||
return types.NewFileSourceFromData(audio.Data, mimeType)
|
||||
case ContentTypeFile:
|
||||
file := m.GetFile()
|
||||
if file == nil || file.FileData == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(file.FileData, "")
|
||||
case ContentTypeVideoUrl:
|
||||
video := m.GetVideoUrl()
|
||||
if video == nil || video.Url == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(video.Url, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type MessageImageUrl struct {
|
||||
Url string `json:"url"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
@@ -865,7 +871,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
if input.ImageUrl != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeImage,
|
||||
Source: createFileSource(input.ImageUrl),
|
||||
Source: types.NewFileSourceFromData(input.ImageUrl, ""),
|
||||
Detail: input.Detail,
|
||||
})
|
||||
}
|
||||
@@ -873,7 +879,7 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
if input.FileUrl != "" {
|
||||
fileMeta = append(fileMeta, &types.FileMeta{
|
||||
FileType: types.FileTypeFile,
|
||||
Source: createFileSource(input.FileUrl),
|
||||
Source: types.NewFileSourceFromData(input.FileUrl, ""),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -272,7 +272,7 @@ type OpenAIResponsesResponse struct {
|
||||
Status json.RawMessage `json:"status"`
|
||||
Error any `json:"error,omitempty"`
|
||||
IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"`
|
||||
Instructions string `json:"instructions"`
|
||||
Instructions json.RawMessage `json:"instructions"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Model string `json:"model"`
|
||||
Output []ResponsesOutput `json:"output"`
|
||||
|
||||
@@ -5,6 +5,28 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type StringValue string
|
||||
|
||||
func (s *StringValue) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
*s = StringValue(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
var raw json.Number
|
||||
if err := json.Unmarshal(data, &raw); err == nil {
|
||||
*s = StringValue(raw.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, &str)
|
||||
}
|
||||
|
||||
func (s StringValue) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(string(s))
|
||||
}
|
||||
|
||||
type IntValue int
|
||||
|
||||
func (i *IntValue) UnmarshalJSON(b []byte) error {
|
||||
|
||||
+10
-10
@@ -9,7 +9,7 @@
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "35.7.5",
|
||||
"electron": "39.8.5",
|
||||
"electron-builder": "^26.7.0"
|
||||
}
|
||||
},
|
||||
@@ -777,9 +777,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz",
|
||||
"integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2145,9 +2145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/electron": {
|
||||
"version": "35.7.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-35.7.5.tgz",
|
||||
"integrity": "sha512-dnL+JvLraKZl7iusXTVTGYs10TKfzUi30uEDTqsmTm0guN9V2tbOjTzyIZbh9n3ygUjgEYyo+igAwMRXIi3IPw==",
|
||||
"version": "39.8.5",
|
||||
"resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz",
|
||||
"integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -3279,9 +3279,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
|
||||
Vendored
+1
-1
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"electron": "35.7.5",
|
||||
"electron": "39.8.5",
|
||||
"electron-builder": "^26.7.0"
|
||||
},
|
||||
"build": {
|
||||
|
||||
@@ -8,9 +8,9 @@ require (
|
||||
github.com/abema/go-mp4 v1.4.1
|
||||
github.com/andybalholm/brotli v1.1.1
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/bytedance/gopkg v0.1.3
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
@@ -63,9 +63,9 @@ require (
|
||||
require (
|
||||
github.com/DmitriyVTitov/size v1.5.0 // indirect
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
@@ -96,7 +96,7 @@ require (
|
||||
github.com/icza/bitio v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.1 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jfreymuth/vorbis v1.0.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
|
||||
@@ -12,18 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc
|
||||
github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI=
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
|
||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -152,8 +152,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs=
|
||||
github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA=
|
||||
github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE=
|
||||
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
|
||||
|
||||
@@ -25,6 +25,19 @@ const (
|
||||
MsgDeleteFailed = "common.delete_failed"
|
||||
MsgAlreadyExists = "common.already_exists"
|
||||
MsgNameCannotBeEmpty = "common.name_cannot_be_empty"
|
||||
MsgBatchTooMany = "common.batch_too_many"
|
||||
)
|
||||
|
||||
// Auth middleware messages
|
||||
const (
|
||||
MsgAuthNotLoggedIn = "auth.not_logged_in"
|
||||
MsgAuthAccessTokenInvalid = "auth.access_token_invalid"
|
||||
MsgAuthUserInfoInvalid = "auth.user_info_invalid"
|
||||
MsgAuthUserIdNotProvided = "auth.user_id_not_provided"
|
||||
MsgAuthUserIdFormatError = "auth.user_id_format_error"
|
||||
MsgAuthUserIdMismatch = "auth.user_id_mismatch"
|
||||
MsgAuthUserBanned = "auth.user_banned"
|
||||
MsgAuthInsufficientPrivilege = "auth.insufficient_privilege"
|
||||
)
|
||||
|
||||
// Token related messages
|
||||
@@ -100,6 +113,7 @@ const (
|
||||
MsgUserTelegramIdEmpty = "user.telegram_id_empty"
|
||||
MsgUserTelegramNotBound = "user.telegram_not_bound"
|
||||
MsgUserLinuxDOIdEmpty = "user.linux_do_id_empty"
|
||||
MsgUserQuotaChangeZero = "user.quota_change_zero"
|
||||
)
|
||||
|
||||
// Quota related messages
|
||||
|
||||
+13
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "Invalid parameters"
|
||||
common.database_error: "Database error, please try again later"
|
||||
common.database_error: "Database error, please contact the administrator"
|
||||
common.retry_later: "Please try again later"
|
||||
common.generate_failed: "Generation failed"
|
||||
common.not_found: "Not found"
|
||||
@@ -21,6 +21,17 @@ common.delete_success: "Deletion successful"
|
||||
common.delete_failed: "Deletion failed"
|
||||
common.already_exists: "Already exists"
|
||||
common.name_cannot_be_empty: "Name cannot be empty"
|
||||
common.batch_too_many: "Too many items in batch request, maximum is {{.Max}}"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "Unauthorized, not logged in and no access token provided"
|
||||
auth.access_token_invalid: "Unauthorized, invalid access token"
|
||||
auth.user_info_invalid: "Unauthorized, invalid user info"
|
||||
auth.user_id_not_provided: "Unauthorized, New-Api-User header not provided"
|
||||
auth.user_id_format_error: "Unauthorized, New-Api-User header format error"
|
||||
auth.user_id_mismatch: "Unauthorized, New-Api-User does not match logged in user"
|
||||
auth.user_banned: "User has been banned"
|
||||
auth.insufficient_privilege: "Unauthorized, insufficient privileges"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "Token name is too long"
|
||||
@@ -90,6 +101,7 @@ user.wechat_id_empty: "WeChat ID is empty!"
|
||||
user.telegram_id_empty: "Telegram ID is empty!"
|
||||
user.telegram_not_bound: "This Telegram account is not bound"
|
||||
user.linux_do_id_empty: "Linux DO ID is empty!"
|
||||
user.quota_change_zero: "Quota change amount cannot be zero"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "Quota cannot be negative!"
|
||||
|
||||
+13
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "无效的参数"
|
||||
common.database_error: "数据库错误,请稍后重试"
|
||||
common.database_error: "数据库出错,请联系管理员"
|
||||
common.retry_later: "请稍后重试"
|
||||
common.generate_failed: "生成失败"
|
||||
common.not_found: "未找到"
|
||||
@@ -22,6 +22,17 @@ common.delete_success: "删除成功"
|
||||
common.delete_failed: "删除失败"
|
||||
common.already_exists: "已存在"
|
||||
common.name_cannot_be_empty: "名称不能为空"
|
||||
common.batch_too_many: "批量请求数量过多,最多 {{.Max}} 条"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "无权进行此操作,未登录且未提供 access token"
|
||||
auth.access_token_invalid: "无权进行此操作,access token 无效"
|
||||
auth.user_info_invalid: "无权进行此操作,用户信息无效"
|
||||
auth.user_id_not_provided: "无权进行此操作,未提供 New-Api-User"
|
||||
auth.user_id_format_error: "无权进行此操作,New-Api-User 格式错误"
|
||||
auth.user_id_mismatch: "无权进行此操作,New-Api-User 与登录用户不匹配"
|
||||
auth.user_banned: "用户已被封禁"
|
||||
auth.insufficient_privilege: "无权进行此操作,权限不足"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "令牌名称过长"
|
||||
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 为空!"
|
||||
user.telegram_id_empty: "Telegram id 为空!"
|
||||
user.telegram_not_bound: "该 Telegram 账户未绑定"
|
||||
user.linux_do_id_empty: "Linux DO id 为空!"
|
||||
user.quota_change_zero: "额度变更量不能为0"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "额度不能为负数!"
|
||||
|
||||
+13
-1
@@ -3,7 +3,7 @@
|
||||
|
||||
# Common messages
|
||||
common.invalid_params: "無效的參數"
|
||||
common.database_error: "資料庫錯誤,請稍後重試"
|
||||
common.database_error: "資料庫出錯,請聯繫管理員"
|
||||
common.retry_later: "請稍後重試"
|
||||
common.generate_failed: "生成失敗"
|
||||
common.not_found: "未找到"
|
||||
@@ -22,6 +22,17 @@ common.delete_success: "刪除成功"
|
||||
common.delete_failed: "刪除失敗"
|
||||
common.already_exists: "已存在"
|
||||
common.name_cannot_be_empty: "名稱不能為空"
|
||||
common.batch_too_many: "批次請求數量過多,最多 {{.Max}} 條"
|
||||
|
||||
# Auth middleware messages
|
||||
auth.not_logged_in: "無權進行此操作,未登入且未提供 access token"
|
||||
auth.access_token_invalid: "無權進行此操作,access token 無效"
|
||||
auth.user_info_invalid: "無權進行此操作,使用者資訊無效"
|
||||
auth.user_id_not_provided: "無權進行此操作,未提供 New-Api-User"
|
||||
auth.user_id_format_error: "無權進行此操作,New-Api-User 格式錯誤"
|
||||
auth.user_id_mismatch: "無權進行此操作,New-Api-User 與登入使用者不匹配"
|
||||
auth.user_banned: "使用者已被封禁"
|
||||
auth.insufficient_privilege: "無權進行此操作,權限不足"
|
||||
|
||||
# Token messages
|
||||
token.name_too_long: "令牌名稱過長"
|
||||
@@ -91,6 +102,7 @@ user.wechat_id_empty: "WeChat id 為空!"
|
||||
user.telegram_id_empty: "Telegram id 為空!"
|
||||
user.telegram_not_bound: "該 Telegram 帳號未綁定"
|
||||
user.linux_do_id_empty: "Linux DO id 為空!"
|
||||
user.quota_change_zero: "額度變更量不能為0"
|
||||
|
||||
# Quota messages
|
||||
quota.negative: "額度不能為負數!"
|
||||
|
||||
+57
-20
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/i18n"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func validUserInfo(username string, role int) bool {
|
||||
@@ -43,17 +46,33 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if accessToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未登录且未提供 access token",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
user := model.ValidateAccessToken(accessToken)
|
||||
user, authErr := model.ValidateAccessToken(accessToken)
|
||||
if authErr != nil {
|
||||
if errors.Is(authErr, model.ErrDatabase) {
|
||||
common.SysLog("ValidateAccessToken database error: " + authErr.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
|
||||
})
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if user != nil && user.Username != "" {
|
||||
if !validUserInfo(user.Username, user.Role) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,用户信息无效",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -67,7 +86,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,access token 无效",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthAccessTokenInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -78,7 +97,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if apiUserIdStr == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未提供 New-Api-User",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdNotProvided),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -87,7 +106,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,New-Api-User 格式错误",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdFormatError),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -96,7 +115,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if id != apiUserId {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,New-Api-User 与登录用户不匹配",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -104,7 +123,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if status.(int) == common.UserStatusDisabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已被封禁",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -112,7 +131,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if role.(int) < minRole {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,权限不足",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -120,7 +139,7 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
if !validUserInfo(username.(string), role.(int)) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,用户信息无效",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -198,7 +217,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
if key == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "未提供 Authorization 请求头",
|
||||
"message": common.TranslateMessage(c, i18n.MsgTokenNotProvided),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -212,19 +231,28 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
|
||||
token, err := model.GetTokenByKey(key, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无效的令牌",
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgTokenInvalid),
|
||||
})
|
||||
} else {
|
||||
common.SysLog("TokenAuthReadOnly GetTokenByKey database error: " + err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userCache, err := model.GetUserCache(token.UserId)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("TokenAuthReadOnly GetUserCache error for user %d: %v", token.UserId, err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
"message": common.TranslateMessage(c, i18n.MsgDatabaseError),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -232,7 +260,7 @@ func TokenAuthReadOnly() func(c *gin.Context) {
|
||||
if userCache.Status != common.UserStatusEnabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "用户已被封禁",
|
||||
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
@@ -309,7 +337,14 @@ func TokenAuth() func(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error())
|
||||
if errors.Is(err, model.ErrDatabase) {
|
||||
common.SysLog("TokenAuth ValidateUserToken database error: " + err.Error())
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError,
|
||||
common.TranslateMessage(c, i18n.MsgDatabaseError))
|
||||
} else {
|
||||
abortWithOpenAiMessage(c, http.StatusUnauthorized,
|
||||
common.TranslateMessage(c, i18n.MsgTokenInvalid))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -331,12 +366,14 @@ func TokenAuth() func(c *gin.Context) {
|
||||
|
||||
userCache, err := model.GetUserCache(token.UserId)
|
||||
if err != nil {
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error())
|
||||
common.SysLog(fmt.Sprintf("TokenAuth GetUserCache error for user %d: %v", token.UserId, err))
|
||||
abortWithOpenAiMessage(c, http.StatusInternalServerError,
|
||||
common.TranslateMessage(c, i18n.MsgDatabaseError))
|
||||
return
|
||||
}
|
||||
userEnabled := userCache.Status == common.UserStatusEnabled
|
||||
if !userEnabled {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁")
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, common.TranslateMessage(c, i18n.MsgAuthUserBanned))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import (
|
||||
|
||||
const (
|
||||
// SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致)
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
SecureVerificationSessionKey = "secure_verified_at"
|
||||
secureVerificationMethodSessionKey = "secure_verified_method"
|
||||
// SecureVerificationTimeout 验证有效期(秒)
|
||||
SecureVerificationTimeout = 300 // 5分钟
|
||||
)
|
||||
@@ -48,8 +49,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
|
||||
verifiedAt, ok := verifiedAtRaw.(int64)
|
||||
if !ok {
|
||||
// session 数据格式错误
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "验证状态异常,请重新验证",
|
||||
@@ -63,8 +63,7 @@ func SecureVerificationRequired() gin.HandlerFunc {
|
||||
elapsed := time.Now().Unix() - verifiedAt
|
||||
if elapsed >= SecureVerificationTimeout {
|
||||
// 验证已过期,清除 session
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": "验证已过期,请重新验证",
|
||||
@@ -74,11 +73,16 @@ func SecureVerificationRequired() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证有效,继续处理请求
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func clearSecureVerificationSession(session sessions.Session) {
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
session.Delete(secureVerificationMethodSessionKey)
|
||||
_ = session.Save()
|
||||
}
|
||||
|
||||
// OptionalSecureVerification 可选的安全验证中间件
|
||||
// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续
|
||||
// 用于某些需要区分是否已验证的场景
|
||||
@@ -109,8 +113,7 @@ func OptionalSecureVerification() gin.HandlerFunc {
|
||||
|
||||
elapsed := time.Now().Unix() - verifiedAt
|
||||
if elapsed >= SecureVerificationTimeout {
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
c.Set("secure_verified", false)
|
||||
c.Next()
|
||||
return
|
||||
@@ -126,6 +129,5 @@ func OptionalSecureVerification() gin.HandlerFunc {
|
||||
// 用于用户登出或需要强制重新验证的场景
|
||||
func ClearSecureVerification(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Delete(SecureVerificationSessionKey)
|
||||
_ = session.Save()
|
||||
clearSecureVerificationSession(session)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "errors"
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrDatabase = errors.New("database error")
|
||||
)
|
||||
|
||||
// User auth errors
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUserEmptyCredentials = errors.New("empty credentials")
|
||||
)
|
||||
|
||||
// Token auth errors
|
||||
var (
|
||||
ErrTokenNotProvided = errors.New("token not provided")
|
||||
ErrTokenInvalid = errors.New("token invalid")
|
||||
)
|
||||
|
||||
// Redemption errors
|
||||
var ErrRedeemFailed = errors.New("redeem.failed")
|
||||
|
||||
// 2FA errors
|
||||
var ErrTwoFANotEnabled = errors.New("2fa not enabled")
|
||||
@@ -90,6 +90,58 @@ func RecordLog(userId int, logType int, content string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RecordLogWithAdminInfo 记录操作日志,并将管理员相关信息存入 Other.admin_info,
|
||||
func RecordLogWithAdminInfo(userId int, logType int, content string, adminInfo map[string]interface{}) {
|
||||
if logType == LogTypeConsume && !common.LogConsumeEnabled {
|
||||
return
|
||||
}
|
||||
username, _ := GetUsernameById(userId, false)
|
||||
log := &Log{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
CreatedAt: common.GetTimestamp(),
|
||||
Type: logType,
|
||||
Content: content,
|
||||
}
|
||||
if len(adminInfo) > 0 {
|
||||
other := map[string]interface{}{
|
||||
"admin_info": adminInfo,
|
||||
}
|
||||
log.Other = common.MapToJsonStr(other)
|
||||
}
|
||||
if err := LOG_DB.Create(log).Error; err != nil {
|
||||
common.SysLog("failed to record log: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func RecordTopupLog(userId int, content string, callerIp string, paymentMethod string, callbackPaymentMethod string) {
|
||||
username, _ := GetUsernameById(userId, false)
|
||||
adminInfo := map[string]interface{}{
|
||||
"server_ip": common.GetIp(),
|
||||
"node_name": common.NodeName,
|
||||
"caller_ip": callerIp,
|
||||
"payment_method": paymentMethod,
|
||||
"callback_payment_method": callbackPaymentMethod,
|
||||
"version": common.Version,
|
||||
}
|
||||
other := map[string]interface{}{
|
||||
"admin_info": adminInfo,
|
||||
}
|
||||
log := &Log{
|
||||
UserId: userId,
|
||||
Username: username,
|
||||
CreatedAt: common.GetTimestamp(),
|
||||
Type: LogTypeTopup,
|
||||
Content: content,
|
||||
Ip: callerIp,
|
||||
Other: common.MapToJsonStr(other),
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
if err != nil {
|
||||
common.SysLog("failed to record topup log: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
|
||||
isStream bool, group string, other map[string]interface{}) {
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
|
||||
|
||||
+40
-1
@@ -62,6 +62,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["SMTPAccount"] = ""
|
||||
common.OptionMap["SMTPToken"] = ""
|
||||
common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled)
|
||||
common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin)
|
||||
common.OptionMap["Notice"] = ""
|
||||
common.OptionMap["About"] = ""
|
||||
common.OptionMap["HomePageContent"] = ""
|
||||
@@ -105,6 +106,18 @@ func InitOptionMap() {
|
||||
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
|
||||
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
|
||||
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
|
||||
common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled)
|
||||
common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox)
|
||||
common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
|
||||
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey
|
||||
common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey
|
||||
common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey
|
||||
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
|
||||
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
|
||||
common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
|
||||
common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency
|
||||
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
|
||||
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
|
||||
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
||||
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
||||
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
||||
@@ -233,7 +246,7 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
common.ImageDownloadPermission = intValue
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" {
|
||||
if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" || key == "SMTPForceAuthLogin" {
|
||||
boolValue := value == "true"
|
||||
switch key {
|
||||
case "PasswordRegisterEnabled":
|
||||
@@ -308,6 +321,8 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.StopOnSensitiveEnabled = boolValue
|
||||
case "SMTPSSLEnabled":
|
||||
common.SMTPSSLEnabled = boolValue
|
||||
case "SMTPForceAuthLogin":
|
||||
common.SMTPForceAuthLogin = boolValue
|
||||
case "WorkerAllowHttpImageRequestEnabled":
|
||||
system_setting.WorkerAllowHttpImageRequestEnabled = boolValue
|
||||
case "DefaultUseAutoGroup":
|
||||
@@ -404,6 +419,30 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
|
||||
case "WaffoMinTopUp":
|
||||
setting.WaffoMinTopUp, _ = strconv.Atoi(value)
|
||||
case "WaffoPancakeEnabled":
|
||||
setting.WaffoPancakeEnabled = value == "true"
|
||||
case "WaffoPancakeSandbox":
|
||||
setting.WaffoPancakeSandbox = value == "true"
|
||||
case "WaffoPancakeMerchantID":
|
||||
setting.WaffoPancakeMerchantID = value
|
||||
case "WaffoPancakePrivateKey":
|
||||
setting.WaffoPancakePrivateKey = value
|
||||
case "WaffoPancakeWebhookPublicKey":
|
||||
setting.WaffoPancakeWebhookPublicKey = value
|
||||
case "WaffoPancakeWebhookTestKey":
|
||||
setting.WaffoPancakeWebhookTestKey = value
|
||||
case "WaffoPancakeStoreID":
|
||||
setting.WaffoPancakeStoreID = value
|
||||
case "WaffoPancakeProductID":
|
||||
setting.WaffoPancakeProductID = value
|
||||
case "WaffoPancakeReturnURL":
|
||||
setting.WaffoPancakeReturnURL = value
|
||||
case "WaffoPancakeCurrency":
|
||||
setting.WaffoPancakeCurrency = value
|
||||
case "WaffoPancakeUnitPrice":
|
||||
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
|
||||
case "WaffoPancakeMinTopUp":
|
||||
setting.WaffoPancakeMinTopUp, _ = strconv.Atoi(value)
|
||||
case "TopupGroupRatio":
|
||||
err = common.UpdateTopupGroupRatioByJSONString(value)
|
||||
case "GitHubClientId":
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func insertUserForPaymentGuardTest(t *testing.T, id int, quota int) {
|
||||
t.Helper()
|
||||
user := &User{
|
||||
Id: id,
|
||||
Username: "payment_guard_user",
|
||||
Status: common.UserStatusEnabled,
|
||||
Quota: quota,
|
||||
}
|
||||
require.NoError(t, DB.Create(user).Error)
|
||||
}
|
||||
|
||||
func insertSubscriptionPlanForPaymentGuardTest(t *testing.T, id int) *SubscriptionPlan {
|
||||
t.Helper()
|
||||
plan := &SubscriptionPlan{
|
||||
Id: id,
|
||||
Title: "Guard Plan",
|
||||
PriceAmount: 9.99,
|
||||
Currency: "USD",
|
||||
DurationUnit: SubscriptionDurationMonth,
|
||||
DurationValue: 1,
|
||||
Enabled: true,
|
||||
TotalAmount: 1000,
|
||||
}
|
||||
require.NoError(t, DB.Create(plan).Error)
|
||||
return plan
|
||||
}
|
||||
|
||||
func insertSubscriptionOrderForPaymentGuardTest(t *testing.T, tradeNo string, userID int, planID int, paymentMethod string) {
|
||||
t.Helper()
|
||||
order := &SubscriptionOrder{
|
||||
UserId: userID,
|
||||
PlanId: planID,
|
||||
Money: 9.99,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: paymentMethod,
|
||||
Status: common.TopUpStatusPending,
|
||||
CreateTime: time.Now().Unix(),
|
||||
}
|
||||
require.NoError(t, order.Insert())
|
||||
}
|
||||
|
||||
func insertTopUpForPaymentGuardTest(t *testing.T, tradeNo string, userID int, paymentMethod string) {
|
||||
t.Helper()
|
||||
topUp := &TopUp{
|
||||
UserId: userID,
|
||||
Amount: 2,
|
||||
Money: 9.99,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: paymentMethod,
|
||||
Status: common.TopUpStatusPending,
|
||||
CreateTime: time.Now().Unix(),
|
||||
}
|
||||
require.NoError(t, topUp.Insert())
|
||||
}
|
||||
|
||||
func getTopUpStatusForPaymentGuardTest(t *testing.T, tradeNo string) string {
|
||||
t.Helper()
|
||||
topUp := GetTopUpByTradeNo(tradeNo)
|
||||
require.NotNil(t, topUp)
|
||||
return topUp.Status
|
||||
}
|
||||
|
||||
func countUserSubscriptionsForPaymentGuardTest(t *testing.T, userID int) int64 {
|
||||
t.Helper()
|
||||
var count int64
|
||||
require.NoError(t, DB.Model(&UserSubscription{}).Where("user_id = ?", userID).Count(&count).Error)
|
||||
return count
|
||||
}
|
||||
|
||||
func getUserQuotaForPaymentGuardTest(t *testing.T, userID int) int {
|
||||
t.Helper()
|
||||
var user User
|
||||
require.NoError(t, DB.Select("quota").Where("id = ?", userID).First(&user).Error)
|
||||
return user.Quota
|
||||
}
|
||||
|
||||
func TestRechargeWaffoPancake_RejectsMismatchedPaymentMethod(t *testing.T) {
|
||||
truncateTables(t)
|
||||
|
||||
insertUserForPaymentGuardTest(t, 101, 0)
|
||||
insertTopUpForPaymentGuardTest(t, "waffo-pancake-guard", 101, PaymentMethodStripe)
|
||||
|
||||
err := RechargeWaffoPancake("waffo-pancake-guard")
|
||||
require.Error(t, err)
|
||||
|
||||
topUp := GetTopUpByTradeNo("waffo-pancake-guard")
|
||||
require.NotNil(t, topUp)
|
||||
assert.Equal(t, common.TopUpStatusPending, topUp.Status)
|
||||
assert.Equal(t, 0, getUserQuotaForPaymentGuardTest(t, 101))
|
||||
}
|
||||
|
||||
func TestUpdatePendingTopUpStatus_RejectsMismatchedPaymentMethod(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
tradeNo string
|
||||
storedPaymentMethod string
|
||||
expectedPaymentMethod string
|
||||
targetStatus string
|
||||
}{
|
||||
{
|
||||
name: "stripe expire",
|
||||
tradeNo: "stripe-expire-guard",
|
||||
storedPaymentMethod: PaymentMethodCreem,
|
||||
expectedPaymentMethod: PaymentMethodStripe,
|
||||
targetStatus: common.TopUpStatusExpired,
|
||||
},
|
||||
{
|
||||
name: "waffo failed",
|
||||
tradeNo: "waffo-failed-guard",
|
||||
storedPaymentMethod: PaymentMethodStripe,
|
||||
expectedPaymentMethod: PaymentMethodWaffo,
|
||||
targetStatus: common.TopUpStatusFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
truncateTables(t)
|
||||
insertUserForPaymentGuardTest(t, 150, 0)
|
||||
insertTopUpForPaymentGuardTest(t, tc.tradeNo, 150, tc.storedPaymentMethod)
|
||||
|
||||
err := UpdatePendingTopUpStatus(tc.tradeNo, tc.expectedPaymentMethod, tc.targetStatus)
|
||||
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
|
||||
assert.Equal(t, common.TopUpStatusPending, getTopUpStatusForPaymentGuardTest(t, tc.tradeNo))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteSubscriptionOrder_RejectsMismatchedPaymentMethod(t *testing.T) {
|
||||
truncateTables(t)
|
||||
|
||||
insertUserForPaymentGuardTest(t, 202, 0)
|
||||
plan := insertSubscriptionPlanForPaymentGuardTest(t, 301)
|
||||
insertSubscriptionOrderForPaymentGuardTest(t, "sub-guard-order", 202, plan.Id, PaymentMethodStripe)
|
||||
|
||||
err := CompleteSubscriptionOrder("sub-guard-order", `{"provider":"epay"}`, "alipay")
|
||||
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
|
||||
|
||||
order := GetSubscriptionOrderByTradeNo("sub-guard-order")
|
||||
require.NotNil(t, order)
|
||||
assert.Equal(t, common.TopUpStatusPending, order.Status)
|
||||
assert.Zero(t, countUserSubscriptionsForPaymentGuardTest(t, 202))
|
||||
|
||||
topUp := GetTopUpByTradeNo("sub-guard-order")
|
||||
assert.Nil(t, topUp)
|
||||
}
|
||||
|
||||
func TestExpireSubscriptionOrder_RejectsMismatchedPaymentMethod(t *testing.T) {
|
||||
truncateTables(t)
|
||||
|
||||
insertUserForPaymentGuardTest(t, 303, 0)
|
||||
plan := insertSubscriptionPlanForPaymentGuardTest(t, 401)
|
||||
insertSubscriptionOrderForPaymentGuardTest(t, "sub-expire-guard", 303, plan.Id, PaymentMethodStripe)
|
||||
|
||||
err := ExpireSubscriptionOrder("sub-expire-guard", PaymentMethodCreem)
|
||||
require.ErrorIs(t, err, ErrPaymentMethodMismatch)
|
||||
|
||||
order := GetSubscriptionOrderByTradeNo("sub-expire-guard")
|
||||
require.NotNil(t, order)
|
||||
assert.Equal(t, common.TopUpStatusPending, order.Status)
|
||||
}
|
||||
@@ -11,9 +11,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrRedeemFailed is returned when redemption fails due to database error
|
||||
var ErrRedeemFailed = errors.New("redeem.failed")
|
||||
|
||||
type Redemption struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
|
||||
+10
-2
@@ -505,7 +505,7 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio
|
||||
}
|
||||
|
||||
// Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan.
|
||||
func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
||||
func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedPaymentMethod string) error {
|
||||
if tradeNo == "" {
|
||||
return errors.New("tradeNo is empty")
|
||||
}
|
||||
@@ -523,6 +523,9 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
||||
return ErrSubscriptionOrderNotFound
|
||||
}
|
||||
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
if order.Status == common.TopUpStatusSuccess {
|
||||
return nil
|
||||
}
|
||||
@@ -596,6 +599,8 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
|
||||
topup.Money = order.Money
|
||||
if topup.PaymentMethod == "" {
|
||||
topup.PaymentMethod = order.PaymentMethod
|
||||
} else if topup.PaymentMethod != order.PaymentMethod {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
if topup.CreateTime == 0 {
|
||||
topup.CreateTime = order.CreateTime
|
||||
@@ -605,7 +610,7 @@ func upsertSubscriptionTopUpTx(tx *gorm.DB, order *SubscriptionOrder) error {
|
||||
return tx.Save(&topup).Error
|
||||
}
|
||||
|
||||
func ExpireSubscriptionOrder(tradeNo string) error {
|
||||
func ExpireSubscriptionOrder(tradeNo string, expectedPaymentMethod string) error {
|
||||
if tradeNo == "" {
|
||||
return errors.New("tradeNo is empty")
|
||||
}
|
||||
@@ -618,6 +623,9 @@ func ExpireSubscriptionOrder(tradeNo string) error {
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
||||
return ErrSubscriptionOrderNotFound
|
||||
}
|
||||
if expectedPaymentMethod != "" && order.PaymentMethod != expectedPaymentMethod {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
if order.Status != common.TopUpStatusPending {
|
||||
return nil
|
||||
}
|
||||
|
||||
+15
-1
@@ -33,7 +33,17 @@ func TestMain(m *testing.M) {
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
if err := db.AutoMigrate(&Task{}, &User{}, &Token{}, &Log{}, &Channel{}); err != nil {
|
||||
if err := db.AutoMigrate(
|
||||
&Task{},
|
||||
&User{},
|
||||
&Token{},
|
||||
&Log{},
|
||||
&Channel{},
|
||||
&TopUp{},
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
); err != nil {
|
||||
panic("failed to migrate: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -48,6 +58,10 @@ func truncateTables(t *testing.T) {
|
||||
DB.Exec("DELETE FROM tokens")
|
||||
DB.Exec("DELETE FROM logs")
|
||||
DB.Exec("DELETE FROM channels")
|
||||
DB.Exec("DELETE FROM top_ups")
|
||||
DB.Exec("DELETE FROM subscription_orders")
|
||||
DB.Exec("DELETE FROM subscription_plans")
|
||||
DB.Exec("DELETE FROM user_subscriptions")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+46
-18
@@ -187,19 +187,14 @@ func SearchUserTokens(userId int, keyword string, token string, offset int, limi
|
||||
|
||||
func ValidateUserToken(key string) (token *Token, err error) {
|
||||
if key == "" {
|
||||
return nil, errors.New("未提供令牌")
|
||||
return nil, ErrTokenNotProvided
|
||||
}
|
||||
token, err = GetTokenByKey(key, false)
|
||||
if err == nil {
|
||||
if token.Status == common.TokenStatusExhausted {
|
||||
keyPrefix := key[:3]
|
||||
keySuffix := key[len(key)-3:]
|
||||
return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]")
|
||||
} else if token.Status == common.TokenStatusExpired {
|
||||
return token, errors.New("该令牌已过期")
|
||||
}
|
||||
if token.Status != common.TokenStatusEnabled {
|
||||
return token, errors.New("该令牌状态不可用")
|
||||
if token.Status == common.TokenStatusExhausted ||
|
||||
token.Status == common.TokenStatusExpired ||
|
||||
token.Status != common.TokenStatusEnabled {
|
||||
return token, ErrTokenInvalid
|
||||
}
|
||||
if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() {
|
||||
if !common.RedisEnabled {
|
||||
@@ -209,29 +204,25 @@ func ValidateUserToken(key string) (token *Token, err error) {
|
||||
common.SysLog("failed to update token status" + err.Error())
|
||||
}
|
||||
}
|
||||
return token, errors.New("该令牌已过期")
|
||||
return token, ErrTokenInvalid
|
||||
}
|
||||
if !token.UnlimitedQuota && token.RemainQuota <= 0 {
|
||||
if !common.RedisEnabled {
|
||||
// in this case, we can make sure the token is exhausted
|
||||
token.Status = common.TokenStatusExhausted
|
||||
err := token.SelectUpdate()
|
||||
if err != nil {
|
||||
common.SysLog("failed to update token status" + err.Error())
|
||||
}
|
||||
}
|
||||
keyPrefix := key[:3]
|
||||
keySuffix := key[len(key)-3:]
|
||||
return token, fmt.Errorf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)
|
||||
return token, ErrTokenInvalid
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("无效的令牌")
|
||||
} else {
|
||||
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
|
||||
}
|
||||
|
||||
func GetTokenByIds(id int, userId int) (*Token, error) {
|
||||
@@ -481,3 +472,40 @@ func BatchDeleteTokens(ids []int, userId int) (int, error) {
|
||||
|
||||
return len(tokens), nil
|
||||
}
|
||||
|
||||
func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
|
||||
var tokens []Token
|
||||
err := DB.Select("id", commonKeyCol).
|
||||
Where("user_id = ? AND id IN (?)", userId, ids).
|
||||
Find(&tokens).Error
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
// InvalidateUserTokensCache 清理指定用户所有令牌在 Redis 中的缓存,
|
||||
// 配合 InvalidateUserCache 使用,可在用户被禁用/删除时立即阻断其令牌的请求。
|
||||
// 下一次请求将从数据库重新加载令牌及用户状态,从而立即识别出被禁用的用户。
|
||||
func InvalidateUserTokensCache(userId int) error {
|
||||
if !common.RedisEnabled {
|
||||
return nil
|
||||
}
|
||||
if userId <= 0 {
|
||||
return errors.New("userId 无效")
|
||||
}
|
||||
var tokens []Token
|
||||
if err := DB.Unscoped().
|
||||
Select("id", commonKeyCol).
|
||||
Where("user_id = ?", userId).
|
||||
Find(&tokens).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var firstErr error
|
||||
for _, t := range tokens {
|
||||
if t.Key == "" {
|
||||
continue
|
||||
}
|
||||
if err := cacheDeleteToken(t.Key); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
+174
-33
@@ -12,17 +12,30 @@ import (
|
||||
)
|
||||
|
||||
type TopUp struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id" gorm:"index"`
|
||||
Amount int64 `json:"amount"`
|
||||
Money float64 `json:"money"`
|
||||
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
|
||||
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
CompleteTime int64 `json:"complete_time"`
|
||||
Status string `json:"status"`
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id" gorm:"index"`
|
||||
Amount int64 `json:"amount"`
|
||||
Money float64 `json:"money"`
|
||||
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
|
||||
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
|
||||
CreateTime int64 `json:"create_time"`
|
||||
CompleteTime int64 `json:"complete_time"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
const (
|
||||
PaymentMethodStripe = "stripe"
|
||||
PaymentMethodCreem = "creem"
|
||||
PaymentMethodWaffo = "waffo"
|
||||
PaymentMethodWaffoPancake = "waffo_pancake"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPaymentMethodMismatch = errors.New("payment method mismatch")
|
||||
ErrTopUpNotFound = errors.New("topup not found")
|
||||
ErrTopUpStatusInvalid = errors.New("topup status invalid")
|
||||
)
|
||||
|
||||
func (topUp *TopUp) Insert() error {
|
||||
var err error
|
||||
err = DB.Create(topUp).Error
|
||||
@@ -55,7 +68,34 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
|
||||
return topUp
|
||||
}
|
||||
|
||||
func Recharge(referenceId string, customerId string) (err error) {
|
||||
func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentMethod string, targetStatus string) error {
|
||||
if tradeNo == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
|
||||
refCol := "`trade_no`"
|
||||
if common.UsingPostgreSQL {
|
||||
refCol = `"trade_no"`
|
||||
}
|
||||
|
||||
return DB.Transaction(func(tx *gorm.DB) error {
|
||||
topUp := &TopUp{}
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
|
||||
return ErrTopUpNotFound
|
||||
}
|
||||
if expectedPaymentMethod != "" && topUp.PaymentMethod != expectedPaymentMethod {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return ErrTopUpStatusInvalid
|
||||
}
|
||||
|
||||
topUp.Status = targetStatus
|
||||
return tx.Save(topUp).Error
|
||||
})
|
||||
}
|
||||
|
||||
func Recharge(referenceId string, customerId string, callerIp string) (err error) {
|
||||
if referenceId == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
@@ -74,6 +114,10 @@ func Recharge(referenceId string, customerId string) (err error) {
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != PaymentMethodStripe {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
@@ -99,11 +143,19 @@ func Recharge(referenceId string, customerId string) (err error) {
|
||||
return errors.New("充值失败,请稍后重试")
|
||||
}
|
||||
|
||||
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount))
|
||||
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", logger.FormatQuota(int(quota)), topUp.Amount), callerIp, topUp.PaymentMethod, PaymentMethodStripe)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// topUpQueryWindowSeconds 限制充值记录查询的时间窗口(秒)。
|
||||
const topUpQueryWindowSeconds int64 = 30 * 24 * 60 * 60
|
||||
|
||||
// topUpQueryCutoff 返回允许查询的最早 create_time(秒级 Unix 时间戳)。
|
||||
func topUpQueryCutoff() int64 {
|
||||
return common.GetTimestamp() - topUpQueryWindowSeconds
|
||||
}
|
||||
|
||||
func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
// Start transaction
|
||||
tx := DB.Begin()
|
||||
@@ -116,15 +168,17 @@ func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, tota
|
||||
}
|
||||
}()
|
||||
|
||||
cutoff := topUpQueryCutoff()
|
||||
|
||||
// Get total count within transaction
|
||||
err = tx.Model(&TopUp{}).Where("user_id = ?", userId).Count(&total).Error
|
||||
err = tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, cutoff).Count(&total).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Get paginated topups within same transaction
|
||||
err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
|
||||
err = tx.Where("user_id = ? AND create_time >= ?", userId, cutoff).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
@@ -138,7 +192,7 @@ func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, tota
|
||||
return topups, total, nil
|
||||
}
|
||||
|
||||
// GetAllTopUps 获取全平台的充值记录(管理员使用)
|
||||
// GetAllTopUps 获取全平台的充值记录(管理员使用,不限制时间窗口)
|
||||
func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
@@ -167,6 +221,10 @@ func GetAllTopUps(pageInfo *common.PageInfo) (topups []*TopUp, total int64, err
|
||||
return topups, total, nil
|
||||
}
|
||||
|
||||
// searchTopUpCountHardLimit 搜索充值记录时 COUNT 的安全上限,
|
||||
// 防止对超大表执行无界 COUNT 触发 DoS。
|
||||
const searchTopUpCountHardLimit = 10000
|
||||
|
||||
// SearchUserTopUps 按订单号搜索某用户的充值记录
|
||||
func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
@@ -179,20 +237,26 @@ func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (to
|
||||
}
|
||||
}()
|
||||
|
||||
query := tx.Model(&TopUp{}).Where("user_id = ?", userId)
|
||||
query := tx.Model(&TopUp{}).Where("user_id = ? AND create_time >= ?", userId, topUpQueryCutoff())
|
||||
if keyword != "" {
|
||||
like := "%%" + keyword + "%%"
|
||||
query = query.Where("trade_no LIKE ?", like)
|
||||
pattern, perr := sanitizeLikePattern(keyword)
|
||||
if perr != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, perr
|
||||
}
|
||||
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
|
||||
}
|
||||
|
||||
if err = query.Count(&total).Error; err != nil {
|
||||
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
common.SysError("failed to count search topups: " + err.Error())
|
||||
return nil, 0, errors.New("搜索充值记录失败")
|
||||
}
|
||||
|
||||
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
common.SysError("failed to search topups: " + err.Error())
|
||||
return nil, 0, errors.New("搜索充值记录失败")
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
@@ -201,7 +265,7 @@ func SearchUserTopUps(userId int, keyword string, pageInfo *common.PageInfo) (to
|
||||
return topups, total, nil
|
||||
}
|
||||
|
||||
// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用)
|
||||
// SearchAllTopUps 按订单号搜索全平台充值记录(管理员使用,不限制时间窗口)
|
||||
func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
|
||||
tx := DB.Begin()
|
||||
if tx.Error != nil {
|
||||
@@ -215,18 +279,24 @@ func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp
|
||||
|
||||
query := tx.Model(&TopUp{})
|
||||
if keyword != "" {
|
||||
like := "%%" + keyword + "%%"
|
||||
query = query.Where("trade_no LIKE ?", like)
|
||||
pattern, perr := sanitizeLikePattern(keyword)
|
||||
if perr != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, perr
|
||||
}
|
||||
query = query.Where("trade_no LIKE ? ESCAPE '!'", pattern)
|
||||
}
|
||||
|
||||
if err = query.Count(&total).Error; err != nil {
|
||||
if err = query.Limit(searchTopUpCountHardLimit).Count(&total).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
common.SysError("failed to count search topups: " + err.Error())
|
||||
return nil, 0, errors.New("搜索充值记录失败")
|
||||
}
|
||||
|
||||
if err = query.Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return nil, 0, err
|
||||
common.SysError("failed to search topups: " + err.Error())
|
||||
return nil, 0, errors.New("搜索充值记录失败")
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
@@ -236,7 +306,7 @@ func SearchAllTopUps(keyword string, pageInfo *common.PageInfo) (topups []*TopUp
|
||||
}
|
||||
|
||||
// ManualCompleteTopUp 管理员手动完成订单并给用户充值
|
||||
func ManualCompleteTopUp(tradeNo string) error {
|
||||
func ManualCompleteTopUp(tradeNo string, callerIp string) error {
|
||||
if tradeNo == "" {
|
||||
return errors.New("未提供订单号")
|
||||
}
|
||||
@@ -249,6 +319,7 @@ func ManualCompleteTopUp(tradeNo string) error {
|
||||
var userId int
|
||||
var quotaToAdd int
|
||||
var payMoney float64
|
||||
var paymentMethod string
|
||||
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
topUp := &TopUp{}
|
||||
@@ -269,7 +340,7 @@ func ManualCompleteTopUp(tradeNo string) error {
|
||||
// 计算应充值额度:
|
||||
// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
|
||||
// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
|
||||
if topUp.PaymentMethod == "stripe" {
|
||||
if topUp.PaymentMethod == PaymentMethodStripe {
|
||||
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
|
||||
quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
|
||||
} else {
|
||||
@@ -295,6 +366,7 @@ func ManualCompleteTopUp(tradeNo string) error {
|
||||
|
||||
userId = topUp.UserId
|
||||
payMoney = topUp.Money
|
||||
paymentMethod = topUp.PaymentMethod
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -303,10 +375,10 @@ func ManualCompleteTopUp(tradeNo string) error {
|
||||
}
|
||||
|
||||
// 事务外记录日志,避免阻塞
|
||||
RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
|
||||
RecordTopupLog(userId, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney), callerIp, paymentMethod, "admin")
|
||||
return nil
|
||||
}
|
||||
func RechargeCreem(referenceId string, customerEmail string, customerName string) (err error) {
|
||||
func RechargeCreem(referenceId string, customerEmail string, customerName string, callerIp string) (err error) {
|
||||
if referenceId == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
@@ -325,6 +397,10 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != PaymentMethodCreem {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
@@ -372,12 +448,12 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
||||
return errors.New("充值失败,请稍后重试")
|
||||
}
|
||||
|
||||
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money))
|
||||
RecordTopupLog(topUp.UserId, fmt.Sprintf("使用Creem充值成功,充值额度: %v,支付金额:%.2f", quota, topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodCreem)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RechargeWaffo(tradeNo string) (err error) {
|
||||
func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
||||
if tradeNo == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
@@ -396,6 +472,10 @@ func RechargeWaffo(tradeNo string) (err error) {
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != PaymentMethodWaffo {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
|
||||
if topUp.Status == common.TopUpStatusSuccess {
|
||||
return nil // 幂等:已成功直接返回
|
||||
}
|
||||
@@ -430,7 +510,68 @@ func RechargeWaffo(tradeNo string) (err error) {
|
||||
}
|
||||
|
||||
if quotaToAdd > 0 {
|
||||
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
|
||||
RecordTopupLog(topUp.UserId, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money), callerIp, topUp.PaymentMethod, PaymentMethodWaffo)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RechargeWaffoPancake(tradeNo string) (err error) {
|
||||
if tradeNo == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
|
||||
var quotaToAdd int
|
||||
topUp := &TopUp{}
|
||||
|
||||
refCol := "`trade_no`"
|
||||
if common.UsingPostgreSQL {
|
||||
refCol = `"trade_no"`
|
||||
}
|
||||
|
||||
err = DB.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error
|
||||
if err != nil {
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != PaymentMethodWaffoPancake {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
|
||||
if topUp.Status == common.TopUpStatusSuccess {
|
||||
return nil
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
|
||||
quotaToAdd = int(decimal.NewFromInt(topUp.Amount).Mul(decimal.NewFromFloat(common.QuotaPerUnit)).IntPart())
|
||||
if quotaToAdd <= 0 {
|
||||
return errors.New("无效的充值额度")
|
||||
}
|
||||
|
||||
topUp.CompleteTime = common.GetTimestamp()
|
||||
topUp.Status = common.TopUpStatusSuccess
|
||||
if err := tx.Save(topUp).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
common.SysError("waffo pancake topup failed: " + err.Error())
|
||||
return errors.New("充值失败,请稍后重试")
|
||||
}
|
||||
|
||||
if quotaToAdd > 0 {
|
||||
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo Pancake充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -10,8 +10,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrTwoFANotEnabled = errors.New("用户未启用2FA")
|
||||
|
||||
// TwoFA 用户2FA设置表
|
||||
type TwoFA struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
|
||||
@@ -115,6 +115,16 @@ func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData
|
||||
return quotaDatas, err
|
||||
}
|
||||
|
||||
func GetQuotaDataGroupByUser(startTime int64, endTime int64) (quotaData []*QuotaData, err error) {
|
||||
var quotaDatas []*QuotaData
|
||||
err = DB.Table("quota_data").
|
||||
Select("username, created_at, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used").
|
||||
Where("created_at >= ? and created_at <= ?", startTime, endTime).
|
||||
Group("username, created_at").
|
||||
Find("aDatas).Error
|
||||
return quotaDatas, err
|
||||
}
|
||||
|
||||
func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) {
|
||||
if username != "" {
|
||||
return GetQuotaDataByUsername(username, startTime, endTime)
|
||||
|
||||
+23
-14
@@ -523,7 +523,6 @@ func (user *User) Edit(updatePassword bool) error {
|
||||
"username": newUser.Username,
|
||||
"display_name": newUser.DisplayName,
|
||||
"group": newUser.Group,
|
||||
"quota": newUser.Quota,
|
||||
"remark": newUser.Remark,
|
||||
}
|
||||
if updatePassword {
|
||||
@@ -598,13 +597,19 @@ func (user *User) ValidateAndFill() (err error) {
|
||||
password := user.Password
|
||||
username := strings.TrimSpace(user.Username)
|
||||
if username == "" || password == "" {
|
||||
return errors.New("用户名或密码为空")
|
||||
return ErrUserEmptyCredentials
|
||||
}
|
||||
// find by username or email
|
||||
err = DB.Where("username = ? OR email = ?", username, username).First(user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrDatabase, err)
|
||||
}
|
||||
// find buy username or email
|
||||
DB.Where("username = ? OR email = ?", username, username).First(user)
|
||||
okay := common.ValidatePasswordAndHash(password, user.Password)
|
||||
if !okay || user.Status != common.UserStatusEnabled {
|
||||
return errors.New("用户名或密码错误,或用户已被封禁")
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -755,16 +760,20 @@ func IsAdmin(userId int) bool {
|
||||
// return user.Status == common.UserStatusEnabled, nil
|
||||
//}
|
||||
|
||||
func ValidateAccessToken(token string) (user *User) {
|
||||
func ValidateAccessToken(token string) (*User, error) {
|
||||
if token == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
token = strings.Replace(token, "Bearer ", "", 1)
|
||||
user = &User{}
|
||||
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
|
||||
return user
|
||||
user := &User{}
|
||||
err := DB.Where("access_token = ?", token).First(user).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", ErrDatabase, err)
|
||||
}
|
||||
return nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetUserQuota gets quota from Redis first, falls back to DB if needed
|
||||
@@ -896,7 +905,7 @@ func increaseUserQuota(id int, quota int) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
func DecreaseUserQuota(id int, quota int) (err error) {
|
||||
func DecreaseUserQuota(id int, quota int, db bool) (err error) {
|
||||
if quota < 0 {
|
||||
return errors.New("quota 不能为负数!")
|
||||
}
|
||||
@@ -906,7 +915,7 @@ func DecreaseUserQuota(id int, quota int) (err error) {
|
||||
common.SysLog("failed to decrease user quota: " + err.Error())
|
||||
}
|
||||
})
|
||||
if common.BatchUpdateEnabled {
|
||||
if !db && common.BatchUpdateEnabled {
|
||||
addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
|
||||
return nil
|
||||
}
|
||||
@@ -928,7 +937,7 @@ func DeltaUpdateUserQuota(id int, delta int) (err error) {
|
||||
if delta > 0 {
|
||||
return IncreaseUserQuota(id, delta, false)
|
||||
} else {
|
||||
return DecreaseUserQuota(id, -delta)
|
||||
return DecreaseUserQuota(id, -delta, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,12 @@ func invalidateUserCache(userId int) error {
|
||||
return common.RedisDelKey(getUserCacheKey(userId))
|
||||
}
|
||||
|
||||
// InvalidateUserCache is the exported version of invalidateUserCache.
|
||||
// 供 controller 等上层包在用户状态变更(如禁用、删除、角色变更)后主动清理缓存。
|
||||
func InvalidateUserCache(userId int) error {
|
||||
return invalidateUserCache(userId)
|
||||
}
|
||||
|
||||
// updateUserCache updates all user cache fields using hash
|
||||
func updateUserCache(user User) error {
|
||||
if !common.RedisEnabled {
|
||||
|
||||
@@ -18,6 +18,7 @@ var awsModelIDMap = map[string]string{
|
||||
"claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
|
||||
"claude-opus-4-7": "anthropic.claude-opus-4-7",
|
||||
// Nova models
|
||||
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||
@@ -91,6 +92,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-opus-4-7": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
|
||||
@@ -26,6 +26,13 @@ var ModelList = []string{
|
||||
"claude-opus-4-6-medium",
|
||||
"claude-opus-4-6-low",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-7-max",
|
||||
"claude-opus-4-7-xhigh",
|
||||
"claude-opus-4-7-high",
|
||||
"claude-opus-4-7-medium",
|
||||
"claude-opus-4-7-low",
|
||||
"claude-opus-4-7-thinking",
|
||||
}
|
||||
|
||||
var ChannelName = "claude"
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
|
||||
require.EqualValues(t, 50, usage.CacheCreationInputTokens)
|
||||
require.EqualValues(t, 53, usage.OutputTokens)
|
||||
require.NotNil(t, usage.CacheCreation)
|
||||
require.EqualValues(t, 10, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 30, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 20, usage.CacheCreation.Ephemeral1hInputTokens)
|
||||
})
|
||||
|
||||
@@ -108,4 +108,22 @@ func TestBuildMessageDeltaPatchUsage(t *testing.T) {
|
||||
require.EqualValues(t, 7, usage.CacheReadInputTokens)
|
||||
require.EqualValues(t, 6, usage.CacheCreationInputTokens)
|
||||
})
|
||||
|
||||
t.Run("default aggregate cache creation to 5m when split missing", func(t *testing.T) {
|
||||
claudeResponse := &dto.ClaudeResponse{Usage: &dto.ClaudeUsage{
|
||||
OutputTokens: 53,
|
||||
CacheCreationInputTokens: 50,
|
||||
}}
|
||||
claudeInfo := &ClaudeResponseInfo{Usage: &dto.Usage{
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedCreationTokens: 50,
|
||||
},
|
||||
}}
|
||||
|
||||
usage := buildMessageDeltaPatchUsage(claudeResponse, claudeInfo)
|
||||
require.NotNil(t, usage)
|
||||
require.NotNil(t, usage.CacheCreation)
|
||||
require.EqualValues(t, 50, usage.CacheCreation.Ephemeral5mInputTokens)
|
||||
require.EqualValues(t, 0, usage.CacheCreation.Ephemeral1hInputTokens)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package claude
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -46,61 +44,6 @@ func maybeMarkClaudeRefusal(c *gin.Context, stopReason string) {
|
||||
}
|
||||
}
|
||||
|
||||
func createClaudeFileSource(file *dto.MessageFile) *types.FileSource {
|
||||
if file == nil || file.FileData == "" {
|
||||
return nil
|
||||
}
|
||||
if strings.HasPrefix(file.FileData, "http://") || strings.HasPrefix(file.FileData, "https://") {
|
||||
return types.NewURLFileSource(file.FileData)
|
||||
}
|
||||
mimeType := ""
|
||||
if ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), "."); ext != "" {
|
||||
if detected := service.GetMimeTypeByExtension(ext); detected != "application/octet-stream" {
|
||||
mimeType = detected
|
||||
}
|
||||
}
|
||||
return types.NewBase64FileSource(file.FileData, mimeType)
|
||||
}
|
||||
|
||||
func buildClaudeFileMessage(c *gin.Context, file *dto.MessageFile) (*dto.ClaudeMediaMessage, error) {
|
||||
source := createClaudeFileSource(file)
|
||||
if source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting document for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %w", err)
|
||||
}
|
||||
switch strings.ToLower(mimeType) {
|
||||
case "application/pdf":
|
||||
return &dto.ClaudeMediaMessage{
|
||||
Type: "document",
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
MediaType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
}, nil
|
||||
case "text/plain":
|
||||
decodedData, err := base64.StdEncoding.DecodeString(base64Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode text file data failed: %w", err)
|
||||
}
|
||||
return &dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer(string(decodedData)),
|
||||
}, nil
|
||||
default:
|
||||
msg := fmt.Sprintf("claude: skip unsupported file content, filename=%q, mime=%q", file.FileName, mimeType)
|
||||
if c != nil {
|
||||
logger.LogInfo(c, msg)
|
||||
} else {
|
||||
common.SysLog(msg)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
|
||||
claudeTools := make([]any, 0, len(textRequest.Tools))
|
||||
|
||||
@@ -142,7 +85,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
|
||||
// 解析 UserLocation JSON
|
||||
var userLocationMap map[string]interface{}
|
||||
if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
|
||||
if err := common.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil {
|
||||
// 检查是否有 approximate 字段
|
||||
if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok {
|
||||
if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" {
|
||||
@@ -211,33 +154,52 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-6") {
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
|
||||
claudeRequest.Model = baseModel
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
claudeRequest.TopP = common.GetPointer[float64](0)
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
claudeRequest.Thinking.Display = "summarized"
|
||||
claudeRequest.Temperature = nil
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.TopK = nil
|
||||
} else {
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
|
||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||
|
||||
// 因为BudgetTokens 必须大于1024
|
||||
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
|
||||
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
|
||||
}
|
||||
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
claudeRequest.Temperature = nil
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.TopK = nil
|
||||
} else {
|
||||
// 因为BudgetTokens 必须大于1024
|
||||
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
|
||||
claudeRequest.MaxTokens = common.GetPointer[uint](1280)
|
||||
}
|
||||
|
||||
// BudgetTokens 为 max_tokens 的 80%
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
|
||||
// BudgetTokens 为 max_tokens 的 80%
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
|
||||
}
|
||||
// TODO: 临时处理
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
claudeRequest.TopP = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
// TODO: 临时处理
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
claudeRequest.TopP = common.GetPointer[float64](0)
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
||||
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
claudeRequest.Model = trimmedModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +277,7 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
formatMessages = formatMessages[:len(formatMessages)-1]
|
||||
}
|
||||
}
|
||||
if fmtMessage.Content == nil {
|
||||
if fmtMessage.Content == nil || (fmtMessage.IsStringContent() && fmtMessage.StringContent() == "") {
|
||||
fmtMessage.SetStringContent("...")
|
||||
}
|
||||
formatMessages = append(formatMessages, fmtMessage)
|
||||
@@ -331,14 +293,16 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
if message.Role == "system" {
|
||||
// 根据Claude API规范,system字段使用数组格式更有通用性
|
||||
if message.IsStringContent() {
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](message.StringContent()),
|
||||
})
|
||||
if text := message.StringContent(); text != "" {
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](text),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// 支持复合内容的system消息(虽然不常见,但需要考虑完整性)
|
||||
for _, ctx := range message.ParseContent() {
|
||||
if ctx.Type == "text" {
|
||||
if ctx.Type == "text" && ctx.Text != "" {
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](ctx.Text),
|
||||
@@ -396,54 +360,49 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
}
|
||||
}
|
||||
} else if message.IsStringContent() && message.ToolCalls == nil {
|
||||
claudeMessage.Content = message.StringContent()
|
||||
text := message.StringContent()
|
||||
if text == "" {
|
||||
text = "..."
|
||||
}
|
||||
claudeMessage.Content = text
|
||||
} else {
|
||||
claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0)
|
||||
for _, mediaMessage := range message.ParseContent() {
|
||||
switch mediaMessage.Type {
|
||||
case "text":
|
||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](mediaMessage.Text),
|
||||
})
|
||||
case dto.ContentTypeImageURL:
|
||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||
Type: "image",
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
},
|
||||
if mediaMessage.Text != "" {
|
||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](mediaMessage.Text),
|
||||
})
|
||||
}
|
||||
imageUrl := mediaMessage.GetImageMedia()
|
||||
if imageUrl == nil {
|
||||
default:
|
||||
source := mediaMessage.ToFileSource()
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
if strings.HasPrefix(imageUrl.Url, "http") {
|
||||
source = types.NewURLFileSource(imageUrl.Url)
|
||||
} else {
|
||||
source = types.NewBase64FileSource(imageUrl.Url, "")
|
||||
}
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
}
|
||||
claudeMediaMessage := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||
claudeMediaMessage.Type = "document"
|
||||
} else {
|
||||
claudeMediaMessage.Type = "image"
|
||||
}
|
||||
|
||||
claudeMediaMessage.Source.MediaType = mimeType
|
||||
claudeMediaMessage.Source.Data = base64Data
|
||||
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
|
||||
// FIXME
|
||||
//case dto.ContentTypeFile:
|
||||
// claudeFileMessage, err := buildClaudeFileMessage(c, mediaMessage.GetFile())
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// if claudeFileMessage != nil {
|
||||
// claudeMediaMessages = append(claudeMediaMessages, *claudeFileMessage)
|
||||
// }
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if message.ToolCalls != nil {
|
||||
for _, toolCall := range message.ParseToolCalls() {
|
||||
inputObj := make(map[string]any)
|
||||
@@ -648,6 +607,11 @@ func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage {
|
||||
return dto.Usage{}
|
||||
}
|
||||
clone := *usage
|
||||
clone.ClaudeCacheCreation5mTokens, clone.ClaudeCacheCreation1hTokens = service.NormalizeCacheCreationSplit(
|
||||
usage.PromptTokensDetails.CachedCreationTokens,
|
||||
usage.ClaudeCacheCreation5mTokens,
|
||||
usage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage)
|
||||
totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens
|
||||
clone.PromptTokens = totalInputTokens
|
||||
@@ -677,11 +641,26 @@ func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo
|
||||
if usage.CacheCreationInputTokens == 0 && claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens > 0 {
|
||||
usage.CacheCreationInputTokens = claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens
|
||||
}
|
||||
if usage.CacheCreation == nil && (claudeInfo.Usage.ClaudeCacheCreation5mTokens > 0 || claudeInfo.Usage.ClaudeCacheCreation1hTokens > 0) {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: claudeInfo.Usage.ClaudeCacheCreation5mTokens,
|
||||
Ephemeral1hInputTokens: claudeInfo.Usage.ClaudeCacheCreation1hTokens,
|
||||
}
|
||||
cacheCreation5m := 0
|
||||
cacheCreation1h := 0
|
||||
if usage.CacheCreation != nil {
|
||||
cacheCreation5m = usage.CacheCreation.Ephemeral5mInputTokens
|
||||
cacheCreation1h = usage.CacheCreation.Ephemeral1hInputTokens
|
||||
} else {
|
||||
cacheCreation5m = claudeInfo.Usage.ClaudeCacheCreation5mTokens
|
||||
cacheCreation1h = claudeInfo.Usage.ClaudeCacheCreation1hTokens
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h = service.NormalizeCacheCreationSplit(
|
||||
usage.CacheCreationInputTokens,
|
||||
cacheCreation5m,
|
||||
cacheCreation1h,
|
||||
)
|
||||
if usage.CacheCreation == nil && (cacheCreation5m > 0 || cacheCreation1h > 0) {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{}
|
||||
}
|
||||
if usage.CacheCreation != nil {
|
||||
usage.CacheCreation.Ephemeral5mInputTokens = cacheCreation5m
|
||||
usage.CacheCreation.Ephemeral1hInputTokens = cacheCreation1h
|
||||
}
|
||||
return usage
|
||||
}
|
||||
@@ -857,7 +836,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
|
||||
if common.DebugEnabled {
|
||||
common.SysLog("claude response usage is not complete, maybe upstream error")
|
||||
}
|
||||
claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens)
|
||||
// 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段
|
||||
fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
if claudeInfo.Usage.CompletionTokens == 0 ||
|
||||
(!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) {
|
||||
claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens
|
||||
}
|
||||
if claudeInfo.Usage.PromptTokens == 0 {
|
||||
claudeInfo.Usage.PromptTokens = fallback.PromptTokens
|
||||
}
|
||||
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
||||
}
|
||||
if claudeInfo.Usage != nil {
|
||||
claudeInfo.Usage.UsageSemantic = "anthropic"
|
||||
|
||||
@@ -258,6 +258,23 @@ func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(t *testing.T) {
|
||||
usage := &dto.Usage{
|
||||
PromptTokens: 100,
|
||||
CompletionTokens: 20,
|
||||
PromptTokensDetails: dto.InputTokenDetails{
|
||||
CachedTokens: 30,
|
||||
CachedCreationTokens: 50,
|
||||
},
|
||||
UsageSemantic: "anthropic",
|
||||
}
|
||||
|
||||
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage)
|
||||
|
||||
require.Equal(t, 50, openAIUsage.ClaudeCacheCreation5mTokens)
|
||||
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-3-5-sonnet",
|
||||
|
||||
@@ -585,14 +585,10 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
Text: part.Text,
|
||||
})
|
||||
}
|
||||
} else if part.Type == dto.ContentTypeImageURL {
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
imageUrl := part.GetImageMedia().Url
|
||||
if strings.HasPrefix(imageUrl, "http") {
|
||||
source = types.NewURLFileSource(imageUrl)
|
||||
} else {
|
||||
source = types.NewBase64FileSource(imageUrl, "")
|
||||
} else {
|
||||
source := part.ToFileSource()
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
|
||||
if err != nil {
|
||||
@@ -604,36 +600,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
|
||||
}
|
||||
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
} else if part.Type == dto.ContentTypeFile {
|
||||
if part.GetFile().FileId != "" {
|
||||
return nil, fmt.Errorf("only base64 file is supported in gemini")
|
||||
}
|
||||
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
})
|
||||
} else if part.Type == dto.ContentTypeInputAudio {
|
||||
if part.GetInputAudio().Data == "" {
|
||||
return nil, fmt.Errorf("only base64 audio is supported in gemini")
|
||||
}
|
||||
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
|
||||
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
|
||||
@@ -78,7 +78,10 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
|
||||
return request, nil
|
||||
if info.RelayMode != constant.RelayModeImagesGenerations {
|
||||
return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode)
|
||||
}
|
||||
return oaiImage2MiniMaxImageRequest(request), nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
@@ -121,6 +124,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
if info.RelayMode == constant.RelayModeAudioSpeech {
|
||||
return handleTTSResponse(c, resp, info)
|
||||
}
|
||||
if info.RelayMode == constant.RelayModeImagesGenerations {
|
||||
return miniMaxImageHandler(c, resp, info)
|
||||
}
|
||||
|
||||
switch info.RelayFormat {
|
||||
case types.RelayFormatClaude:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestGetRequestURLForImageGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesGenerations,
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelBaseUrl: "https://api.minimax.chat",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := GetRequestURL(info)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRequestURL returned error: %v", err)
|
||||
}
|
||||
|
||||
want := "https://api.minimax.chat/v1/image_generation"
|
||||
if got != want {
|
||||
t.Fatalf("GetRequestURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertImageRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adaptor := &Adaptor{}
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesGenerations,
|
||||
OriginModelName: "image-01",
|
||||
}
|
||||
request := dto.ImageRequest{
|
||||
Model: "image-01",
|
||||
Prompt: "a red fox in snowfall",
|
||||
Size: "1536x1024",
|
||||
ResponseFormat: "url",
|
||||
N: uintPtr(2),
|
||||
}
|
||||
|
||||
got, err := adaptor.ConvertImageRequest(gin.CreateTestContextOnly(httptest.NewRecorder(), gin.New()), info, request)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertImageRequest returned error: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v", err)
|
||||
}
|
||||
|
||||
if payload["model"] != "image-01" {
|
||||
t.Fatalf("model = %#v, want %q", payload["model"], "image-01")
|
||||
}
|
||||
if payload["prompt"] != request.Prompt {
|
||||
t.Fatalf("prompt = %#v, want %q", payload["prompt"], request.Prompt)
|
||||
}
|
||||
if payload["n"] != float64(2) {
|
||||
t.Fatalf("n = %#v, want 2", payload["n"])
|
||||
}
|
||||
if payload["aspect_ratio"] != "3:2" {
|
||||
t.Fatalf("aspect_ratio = %#v, want %q", payload["aspect_ratio"], "3:2")
|
||||
}
|
||||
if payload["response_format"] != "url" {
|
||||
t.Fatalf("response_format = %#v, want %q", payload["response_format"], "url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoResponseForImageGeneration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayMode: relayconstant.RelayModeImagesGenerations,
|
||||
StartTime: time.Unix(1700000000, 0),
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: httptest.NewRecorder().Result().Body,
|
||||
}
|
||||
resp.Body = ioNopCloser(`{"data":{"image_urls":["https://example.com/minimax.png"]}}`)
|
||||
|
||||
adaptor := &Adaptor{}
|
||||
usage, err := adaptor.DoResponse(c, resp, info)
|
||||
if err != nil {
|
||||
t.Fatalf("DoResponse returned error: %v", err)
|
||||
}
|
||||
if usage == nil {
|
||||
t.Fatalf("DoResponse returned nil usage")
|
||||
}
|
||||
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"url":"https://example.com/minimax.png"`) {
|
||||
t.Fatalf("response body = %s, want OpenAI image response with image URL", body)
|
||||
}
|
||||
if strings.Contains(body, `"image_urls"`) {
|
||||
t.Fatalf("response body = %s, should not expose raw MiniMax image_urls payload", body)
|
||||
}
|
||||
}
|
||||
|
||||
type nopReadCloser struct {
|
||||
*strings.Reader
|
||||
}
|
||||
|
||||
func (n nopReadCloser) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ioNopCloser(body string) nopReadCloser {
|
||||
return nopReadCloser{Reader: strings.NewReader(body)}
|
||||
}
|
||||
|
||||
func uintPtr(v uint) *uint {
|
||||
return &v
|
||||
}
|
||||
@@ -8,6 +8,8 @@ var ModelList = []string{
|
||||
"abab6-chat",
|
||||
"abab5.5-chat",
|
||||
"abab5.5s-chat",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"speech-2.5-hd-preview",
|
||||
"speech-2.5-turbo-preview",
|
||||
"speech-02-hd",
|
||||
@@ -19,6 +21,8 @@ var ModelList = []string{
|
||||
"MiniMax-M2",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"image-01",
|
||||
"image-01-live",
|
||||
}
|
||||
|
||||
var ChannelName = "minimax"
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package minimax
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MiniMaxImageRequest struct {
|
||||
Model string `json:"model"`
|
||||
Prompt string `json:"prompt"`
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
ResponseFormat string `json:"response_format,omitempty"`
|
||||
N int `json:"n,omitempty"`
|
||||
PromptOptimizer *bool `json:"prompt_optimizer,omitempty"`
|
||||
AigcWatermark *bool `json:"aigc_watermark,omitempty"`
|
||||
}
|
||||
|
||||
type MiniMaxImageResponse struct {
|
||||
ID string `json:"id"`
|
||||
Data struct {
|
||||
ImageURLs []string `json:"image_urls"`
|
||||
ImageBase64 []string `json:"image_base64"`
|
||||
} `json:"data"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
BaseResp struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
StatusMsg string `json:"status_msg"`
|
||||
} `json:"base_resp"`
|
||||
}
|
||||
|
||||
func oaiImage2MiniMaxImageRequest(request dto.ImageRequest) MiniMaxImageRequest {
|
||||
responseFormat := normalizeMiniMaxResponseFormat(request.ResponseFormat)
|
||||
minimaxRequest := MiniMaxImageRequest{
|
||||
Model: request.Model,
|
||||
Prompt: request.Prompt,
|
||||
ResponseFormat: responseFormat,
|
||||
N: 1,
|
||||
AigcWatermark: request.Watermark,
|
||||
}
|
||||
|
||||
if request.Model == "" {
|
||||
minimaxRequest.Model = "image-01"
|
||||
}
|
||||
if request.N != nil && *request.N > 0 {
|
||||
minimaxRequest.N = int(*request.N)
|
||||
}
|
||||
if aspectRatio := aspectRatioFromImageRequest(request); aspectRatio != "" {
|
||||
minimaxRequest.AspectRatio = aspectRatio
|
||||
}
|
||||
if raw, ok := request.Extra["prompt_optimizer"]; ok {
|
||||
var promptOptimizer bool
|
||||
if err := common.Unmarshal(raw, &promptOptimizer); err == nil {
|
||||
minimaxRequest.PromptOptimizer = &promptOptimizer
|
||||
}
|
||||
}
|
||||
|
||||
return minimaxRequest
|
||||
}
|
||||
|
||||
func aspectRatioFromImageRequest(request dto.ImageRequest) string {
|
||||
if raw, ok := request.Extra["aspect_ratio"]; ok {
|
||||
var aspectRatio string
|
||||
if err := common.Unmarshal(raw, &aspectRatio); err == nil && aspectRatio != "" {
|
||||
return aspectRatio
|
||||
}
|
||||
}
|
||||
|
||||
switch request.Size {
|
||||
case "1024x1024":
|
||||
return "1:1"
|
||||
case "1792x1024":
|
||||
return "16:9"
|
||||
case "1024x1792":
|
||||
return "9:16"
|
||||
case "1536x1024", "1248x832":
|
||||
return "3:2"
|
||||
case "1024x1536", "832x1248":
|
||||
return "2:3"
|
||||
case "1152x864":
|
||||
return "4:3"
|
||||
case "864x1152":
|
||||
return "3:4"
|
||||
case "1344x576":
|
||||
return "21:9"
|
||||
}
|
||||
|
||||
width, height, ok := parseImageSize(request.Size)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
ratio := reduceAspectRatio(width, height)
|
||||
switch ratio {
|
||||
case "1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9":
|
||||
return ratio
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func parseImageSize(size string) (int, int, bool) {
|
||||
parts := strings.Split(size, "x")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
width, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
height, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return width, height, true
|
||||
}
|
||||
|
||||
func reduceAspectRatio(width, height int) string {
|
||||
divisor := gcd(width, height)
|
||||
return fmt.Sprintf("%d:%d", width/divisor, height/divisor)
|
||||
}
|
||||
|
||||
func gcd(a, b int) int {
|
||||
for b != 0 {
|
||||
a, b = b, a%b
|
||||
}
|
||||
if a == 0 {
|
||||
return 1
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func normalizeMiniMaxResponseFormat(responseFormat string) string {
|
||||
switch strings.ToLower(responseFormat) {
|
||||
case "", "url":
|
||||
return "url"
|
||||
case "b64_json", "base64":
|
||||
return "base64"
|
||||
default:
|
||||
return responseFormat
|
||||
}
|
||||
}
|
||||
|
||||
func responseMiniMax2OpenAIImage(response *MiniMaxImageResponse, info *relaycommon.RelayInfo) (*dto.ImageResponse, error) {
|
||||
imageResponse := &dto.ImageResponse{
|
||||
Created: info.StartTime.Unix(),
|
||||
}
|
||||
|
||||
for _, imageURL := range response.Data.ImageURLs {
|
||||
imageResponse.Data = append(imageResponse.Data, dto.ImageData{Url: imageURL})
|
||||
}
|
||||
for _, imageBase64 := range response.Data.ImageBase64 {
|
||||
imageResponse.Data = append(imageResponse.Data, dto.ImageData{B64Json: imageBase64})
|
||||
}
|
||||
if len(response.Metadata) > 0 {
|
||||
metadata, err := common.Marshal(response.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imageResponse.Metadata = metadata
|
||||
}
|
||||
|
||||
return imageResponse, nil
|
||||
}
|
||||
|
||||
func miniMaxImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError)
|
||||
}
|
||||
service.CloseResponseBodyGracefully(resp)
|
||||
|
||||
var minimaxResponse MiniMaxImageResponse
|
||||
if err := common.Unmarshal(responseBody, &minimaxResponse); err != nil {
|
||||
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
|
||||
}
|
||||
if minimaxResponse.BaseResp.StatusCode != 0 {
|
||||
return nil, types.WithOpenAIError(types.OpenAIError{
|
||||
Message: minimaxResponse.BaseResp.StatusMsg,
|
||||
Type: "minimax_image_error",
|
||||
Code: fmt.Sprintf("%d", minimaxResponse.BaseResp.StatusCode),
|
||||
}, resp.StatusCode)
|
||||
}
|
||||
|
||||
openAIResponse, err := responseMiniMax2OpenAIImage(&minimaxResponse, info)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
jsonResponse, err := common.Marshal(openAIResponse)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "application/json")
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
if _, err := c.Writer.Write(jsonResponse); err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
|
||||
return &dto.Usage{}, nil
|
||||
}
|
||||
@@ -21,6 +21,8 @@ func GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
switch info.RelayMode {
|
||||
case constant.RelayModeChatCompletions:
|
||||
return fmt.Sprintf("%s/v1/text/chatcompletion_v2", baseUrl), nil
|
||||
case constant.RelayModeImagesGenerations:
|
||||
return fmt.Sprintf("%s/v1/image_generation", baseUrl), nil
|
||||
case constant.RelayModeAudioSpeech:
|
||||
return fmt.Sprintf("%s/v1/t2a_v2", baseUrl), nil
|
||||
default:
|
||||
|
||||
@@ -98,15 +98,8 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
|
||||
parts := m.ParseContent()
|
||||
for _, part := range parts {
|
||||
if part.Type == dto.ContentTypeImageURL {
|
||||
img := part.GetImageMedia()
|
||||
if img != nil && img.Url != "" {
|
||||
// 使用统一的文件服务获取图片数据
|
||||
var source *types.FileSource
|
||||
if strings.HasPrefix(img.Url, "http") {
|
||||
source = types.NewURLFileSource(img.Url)
|
||||
} else {
|
||||
source = types.NewBase64FileSource(img.Url, "")
|
||||
}
|
||||
source := part.ToFileSource()
|
||||
if source != nil {
|
||||
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -136,8 +136,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
task = "chat/completions" + task
|
||||
}
|
||||
|
||||
// 特殊处理 responses API
|
||||
if info.RelayMode == relayconstant.RelayModeResponses {
|
||||
// 特殊处理 responses API(包含 compact)
|
||||
if info.RelayMode == relayconstant.RelayModeResponses || info.RelayMode == relayconstant.RelayModeResponsesCompact {
|
||||
responsesApiVersion := "preview"
|
||||
|
||||
subUrl := "/openai/v1/responses"
|
||||
@@ -150,6 +150,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
|
||||
}
|
||||
|
||||
// compact 模式追加 /compact
|
||||
if info.RelayMode == relayconstant.RelayModeResponsesCompact {
|
||||
subUrl = subUrl + "/compact"
|
||||
}
|
||||
|
||||
requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion)
|
||||
return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, requestURL, info.ChannelType), nil
|
||||
}
|
||||
@@ -369,7 +374,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
|
||||
a.ResponseFormat = request.ResponseFormat
|
||||
if info.RelayMode == relayconstant.RelayModeAudioSpeech {
|
||||
jsonData, err := json.Marshal(request)
|
||||
jsonData, err := common.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error marshalling object: %w", err)
|
||||
}
|
||||
|
||||
@@ -80,9 +80,9 @@ type AliVideoOutput struct {
|
||||
|
||||
// AliUsage 使用统计
|
||||
type AliUsage struct {
|
||||
Duration int `json:"duration,omitempty"`
|
||||
VideoCount int `json:"video_count,omitempty"`
|
||||
SR int `json:"SR,omitempty"`
|
||||
Duration dto.IntValue `json:"duration,omitempty"`
|
||||
VideoCount dto.IntValue `json:"video_count,omitempty"`
|
||||
SR dto.IntValue `json:"SR,omitempty"`
|
||||
}
|
||||
|
||||
type AliMetadata struct {
|
||||
|
||||
@@ -132,6 +132,49 @@ func (a *TaskAdaptor) BuildRequestHeader(_ *gin.Context, req *http.Request, _ *r
|
||||
return nil
|
||||
}
|
||||
|
||||
// EstimateBilling 检测请求 metadata 中是否包含视频输入,返回视频折扣 OtherRatio。
|
||||
func (a *TaskAdaptor) EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 {
|
||||
req, err := relaycommon.GetTaskRequest(c)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if hasVideoInMetadata(req.Metadata) {
|
||||
if ratio, ok := GetVideoInputRatio(info.OriginModelName); ok {
|
||||
return map[string]float64{"video_input": ratio}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasVideoInMetadata 直接检查 metadata 的 content 数组是否包含 video_url 条目,
|
||||
// 避免构建完整的上游 requestPayload。
|
||||
func hasVideoInMetadata(metadata map[string]interface{}) bool {
|
||||
if metadata == nil {
|
||||
return false
|
||||
}
|
||||
contentRaw, ok := metadata["content"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
contentSlice, ok := contentRaw.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, item := range contentSlice {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if itemMap["type"] == "video_url" {
|
||||
return true
|
||||
}
|
||||
if _, has := itemMap["video_url"]; has {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildRequestBody converts request into Doubao specific format.
|
||||
func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) {
|
||||
req, err := relaycommon.GetTaskRequest(c)
|
||||
|
||||
@@ -10,3 +10,16 @@ var ModelList = []string{
|
||||
}
|
||||
|
||||
var ChannelName = "doubao-video"
|
||||
|
||||
// videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。
|
||||
// 管理员应将 ModelRatio 设置为"不含视频"的较高费率,
|
||||
// 系统在检测到视频输入时自动乘以此折扣。
|
||||
var videoInputRatioMap = map[string]float64{
|
||||
"doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087
|
||||
"doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946
|
||||
}
|
||||
|
||||
func GetVideoInputRatio(modelName string) (float64, bool) {
|
||||
r, ok := videoInputRatioMap[modelName]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ var claudeModelMap = map[string]string{
|
||||
"claude-haiku-4-5-20251001": "claude-haiku-4-5@20251001",
|
||||
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
"claude-opus-4-7": "claude-opus-4-7",
|
||||
}
|
||||
|
||||
const anthropicVersion = "vertex-2023-10-16"
|
||||
|
||||
@@ -64,6 +64,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
}
|
||||
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
|
||||
case relayconstant.RelayModeImagesGenerations:
|
||||
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
|
||||
return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil
|
||||
}
|
||||
return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
|
||||
default:
|
||||
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {
|
||||
|
||||
+32
-13
@@ -53,30 +53,49 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-6") {
|
||||
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
|
||||
request.Model = baseModel
|
||||
request.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
request.Thinking.Display = "summarized"
|
||||
request.Temperature = nil
|
||||
request.TopP = nil
|
||||
request.TopK = nil
|
||||
} else {
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
info.UpstreamModelName = request.Model
|
||||
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
|
||||
strings.HasSuffix(request.Model, "-thinking") {
|
||||
if request.Thinking == nil {
|
||||
// 因为BudgetTokens 必须大于1024
|
||||
if request.MaxTokens == nil || *request.MaxTokens < 1280 {
|
||||
request.MaxTokens = common.GetPointer[uint](1280)
|
||||
}
|
||||
baseModel := strings.TrimSuffix(request.Model, "-thinking")
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
request.Temperature = nil
|
||||
request.TopP = nil
|
||||
request.TopK = nil
|
||||
} else {
|
||||
// 因为BudgetTokens 必须大于1024
|
||||
if request.MaxTokens == nil || *request.MaxTokens < 1280 {
|
||||
request.MaxTokens = common.GetPointer[uint](1280)
|
||||
}
|
||||
|
||||
// BudgetTokens 为 max_tokens 的 80%
|
||||
request.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
|
||||
// BudgetTokens 为 max_tokens 的 80%
|
||||
request.Thinking = &dto.Thinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
|
||||
}
|
||||
// TODO: 临时处理
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
// TODO: 临时处理
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
|
||||
request.Temperature = common.GetPointer[float64](1.0)
|
||||
}
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
|
||||
request.Model = strings.TrimSuffix(request.Model, "-thinking")
|
||||
|
||||
@@ -32,6 +32,7 @@ var paramOverrideKeyAuditPaths = map[string]struct{}{
|
||||
"upstream_model": {},
|
||||
"service_tier": {},
|
||||
"inference_geo": {},
|
||||
"speed": {},
|
||||
}
|
||||
|
||||
type paramOverrideAuditRecorder struct {
|
||||
|
||||
@@ -2038,6 +2038,8 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
|
||||
input := `{
|
||||
"service_tier":"flex",
|
||||
"inference_geo":"eu",
|
||||
"speed":"fast",
|
||||
"cache_control":{"type":"ephemeral"},
|
||||
"safety_identifier":"user-123",
|
||||
"store":true,
|
||||
"stream_options":{"include_obfuscation":false}
|
||||
@@ -2048,7 +2050,7 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, `{"store":true}`, string(out))
|
||||
assertJSONEqual(t, `{"cache_control":{"type":"ephemeral"},"store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
|
||||
@@ -2067,6 +2069,22 @@ func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
|
||||
assertJSONEqual(t, `{"inference_geo":"eu","store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsAllowSpeed(t *testing.T) {
|
||||
input := `{
|
||||
"speed":"fast",
|
||||
"store":true
|
||||
}`
|
||||
settings := dto.ChannelOtherSettings{
|
||||
AllowSpeed: true,
|
||||
}
|
||||
|
||||
out, err := RemoveDisabledFields([]byte(input), settings, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
assertJSONEqual(t, `{"speed":"fast","store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestApplyParamOverrideWithRelayInfoRecordsOperationAuditInDebugMode(t *testing.T) {
|
||||
originalDebugEnabled := common2.DebugEnabled
|
||||
common2.DebugEnabled = true
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -437,6 +438,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
if request != nil {
|
||||
isStream = request.IsStream(c)
|
||||
}
|
||||
c.Set(string(constant.ContextKeyIsStream), isStream)
|
||||
|
||||
// firstResponseTime = time.Now() - 1 second
|
||||
|
||||
@@ -690,6 +692,7 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
|
||||
type Alias TaskSubmitReq
|
||||
aux := &struct {
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Duration json.RawMessage `json:"duration,omitempty"`
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(t),
|
||||
@@ -699,6 +702,20 @@ func (t *TaskSubmitReq) UnmarshalJSON(data []byte) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(aux.Duration) > 0 {
|
||||
var durationInt int
|
||||
if err := common.Unmarshal(aux.Duration, &durationInt); err == nil {
|
||||
t.Duration = durationInt
|
||||
} else {
|
||||
var durationStr string
|
||||
if err := common.Unmarshal(aux.Duration, &durationStr); err == nil && durationStr != "" {
|
||||
if v, err := strconv.Atoi(durationStr); err == nil {
|
||||
t.Duration = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(aux.Metadata) > 0 {
|
||||
var metadataStr string
|
||||
if err := common.Unmarshal(aux.Metadata, &metadataStr); err == nil && metadataStr != "" {
|
||||
@@ -754,6 +771,7 @@ func FailTaskInfo(reason string) *TaskInfo {
|
||||
// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段
|
||||
// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持)
|
||||
// inference_geo: Claude 数据驻留推理区域字段(仅 Claude 支持,默认过滤)
|
||||
// speed: Claude 推理速度模式字段(仅 Claude 支持,默认过滤)
|
||||
// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用)
|
||||
// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私)
|
||||
// stream_options.include_obfuscation: 响应流混淆控制字段(仅 OpenAI Responses API 支持)
|
||||
@@ -782,6 +800,13 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
|
||||
}
|
||||
}
|
||||
|
||||
// 默认移除 speed,除非明确允许(避免意外切换 Claude 推理速度模式)
|
||||
if !channelOtherSettings.AllowSpeed {
|
||||
if _, exists := data["speed"]; exists {
|
||||
delete(data, "speed")
|
||||
}
|
||||
}
|
||||
|
||||
// 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用)
|
||||
if channelOtherSettings.DisableStore {
|
||||
if _, exists := data["store"]; exists {
|
||||
|
||||
@@ -204,7 +204,9 @@ func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *d
|
||||
if err != nil {
|
||||
return createTaskError(err, "invalid_multipart_form", http.StatusBadRequest, true)
|
||||
}
|
||||
} else if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
}
|
||||
// 为了metadata字段的兼容性,统一UnmarshalBodyReusable
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
return createTaskError(err, "invalid_request", http.StatusBadRequest, true)
|
||||
}
|
||||
|
||||
|
||||
+47
-17
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
@@ -13,6 +14,21 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func modelPriceNotConfiguredError(modelName string, userId int) error {
|
||||
if model.IsAdmin(userId) {
|
||||
return fmt.Errorf(
|
||||
"模型 %s 的价格未配置。请前往「系统设置 → 运营设置」开启自用模式,或在「系统设置 → 分组与模型定价设置」中为该模型配置价格;"+
|
||||
"Model %s price not configured. Go to System Settings → Operation Settings to enable self-use mode, or configure the model price in System Settings → Group & Model Pricing.",
|
||||
modelName, modelName,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"模型 %s 的价格尚未由管理员配置,暂时无法使用,请联系站点管理员开启该模型;"+
|
||||
"Model %s has not been priced by the administrator yet. Please contact the site administrator to enable this model.",
|
||||
modelName, modelName,
|
||||
)
|
||||
}
|
||||
|
||||
// https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration
|
||||
const claudeCacheCreation1hMultiplier = 6 / 3.75
|
||||
|
||||
@@ -75,7 +91,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
acceptUnsetRatio = true
|
||||
}
|
||||
if !acceptUnsetRatio {
|
||||
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
|
||||
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
|
||||
}
|
||||
}
|
||||
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
|
||||
@@ -139,47 +155,61 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
|
||||
return priceData, nil
|
||||
}
|
||||
|
||||
// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task)
|
||||
// ModelPriceHelperPerCall 按次/按量计费的 PriceHelper (MJ、Task)
|
||||
func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, error) {
|
||||
groupRatioInfo := HandleGroupRatio(c, info)
|
||||
|
||||
modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true)
|
||||
// 如果没有配置价格,检查模型倍率配置
|
||||
if !success {
|
||||
usePrice := success
|
||||
var modelRatio float64
|
||||
|
||||
// 没有配置费用,也要使用默认费用,否则按费率计费模型无法使用
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName]
|
||||
if ok {
|
||||
modelPrice = defaultPrice
|
||||
usePrice = true
|
||||
} else {
|
||||
// 没有配置倍率也不接受没配置,那就返回错误
|
||||
_, ratioSuccess, matchName := ratio_setting.GetModelRatio(info.OriginModelName)
|
||||
var ratioSuccess bool
|
||||
var matchName string
|
||||
modelRatio, ratioSuccess, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
|
||||
acceptUnsetRatio := false
|
||||
if info.UserSetting.AcceptUnsetRatioModel {
|
||||
acceptUnsetRatio = true
|
||||
}
|
||||
if !ratioSuccess && !acceptUnsetRatio {
|
||||
return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName)
|
||||
return types.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
|
||||
}
|
||||
// 未配置价格但配置了倍率,使用默认预扣价格
|
||||
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
|
||||
}
|
||||
|
||||
}
|
||||
quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
|
||||
// 免费模型检测(与 ModelPriceHelper 对齐)
|
||||
var quota int
|
||||
freeModel := false
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
|
||||
if usePrice {
|
||||
quota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 按量计费:以模型倍率的一半作为预扣额度
|
||||
quota = int(modelRatio / 2 * common.QuotaPerUnit * groupRatioInfo.GroupRatio)
|
||||
modelPrice = -1
|
||||
if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume {
|
||||
if groupRatioInfo.GroupRatio == 0 || modelRatio == 0 {
|
||||
quota = 0
|
||||
freeModel = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
priceData := types.PriceData{
|
||||
FreeModel: freeModel,
|
||||
ModelPrice: modelPrice,
|
||||
ModelRatio: modelRatio,
|
||||
UsePrice: usePrice,
|
||||
Quota: quota,
|
||||
GroupRatioInfo: groupRatioInfo,
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
if err != nil {
|
||||
info.OriginModelName = originModelName
|
||||
info.PriceData = originPriceData
|
||||
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry())
|
||||
return types.NewError(err, types.ErrorCodeModelPriceError, types.ErrOptionWithSkipRetry(), types.ErrOptionWithStatusCode(http.StatusBadRequest))
|
||||
}
|
||||
service.PostTextConsumeQuota(c, info, usageDto, nil)
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
|
||||
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
|
||||
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
|
||||
|
||||
// Universal secure verification routes
|
||||
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
|
||||
@@ -90,7 +91,10 @@ func SetApiRouter(router *gin.Engine) {
|
||||
selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
|
||||
selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
|
||||
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
|
||||
selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount)
|
||||
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
|
||||
//selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
|
||||
//selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
|
||||
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
|
||||
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
||||
|
||||
@@ -257,6 +261,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
tokenRoute.PUT("/", controller.UpdateToken)
|
||||
tokenRoute.DELETE("/:id", controller.DeleteToken)
|
||||
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
|
||||
tokenRoute.POST("/batch/keys", middleware.CriticalRateLimit(), middleware.DisableCache(), controller.GetTokenKeysBatch)
|
||||
}
|
||||
|
||||
usageRoute := apiRouter.Group("/usage")
|
||||
@@ -292,6 +297,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
|
||||
dataRoute := apiRouter.Group("/data")
|
||||
dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates)
|
||||
dataRoute.GET("/users", middleware.AdminAuth(), controller.GetQuotaDatesByUser)
|
||||
dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates)
|
||||
|
||||
logRoute.Use(middleware.CORS(), middleware.CriticalRateLimit())
|
||||
|
||||
+1
-38
@@ -2,11 +2,9 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
@@ -44,7 +42,7 @@ func EnableChannel(channelId int, usingKey string, channelName string) {
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
|
||||
func ShouldDisableChannel(err *types.NewAPIError) bool {
|
||||
if !common.AutomaticDisableChannelEnabled {
|
||||
return false
|
||||
}
|
||||
@@ -60,41 +58,6 @@ func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool {
|
||||
if operation_setting.ShouldDisableByStatusCode(err.StatusCode) {
|
||||
return true
|
||||
}
|
||||
//if err.StatusCode == http.StatusUnauthorized {
|
||||
// return true
|
||||
//}
|
||||
if err.StatusCode == http.StatusForbidden {
|
||||
switch channelType {
|
||||
case constant.ChannelTypeGemini:
|
||||
return true
|
||||
}
|
||||
}
|
||||
oaiErr := err.ToOpenAIError()
|
||||
switch oaiErr.Code {
|
||||
case "invalid_api_key":
|
||||
return true
|
||||
case "account_deactivated":
|
||||
return true
|
||||
case "billing_not_active":
|
||||
return true
|
||||
case "pre_consume_token_quota_failed":
|
||||
return true
|
||||
case "Arrearage":
|
||||
return true
|
||||
}
|
||||
switch oaiErr.Type {
|
||||
case "insufficient_quota":
|
||||
return true
|
||||
case "insufficient_user_quota":
|
||||
return true
|
||||
// https://docs.anthropic.com/claude/reference/errors
|
||||
case "authentication_error":
|
||||
return true
|
||||
case "permission_error":
|
||||
return true
|
||||
case "forbidden":
|
||||
return true
|
||||
}
|
||||
|
||||
lowerMessage := strings.ToLower(err.Error())
|
||||
search, _ := AcSearch(lowerMessage, operation_setting.AutomaticDisableKeywords, true)
|
||||
|
||||
@@ -166,12 +166,22 @@ func GetChannelAffinityCacheStats() ChannelAffinityCacheStats {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
if rule.IncludeUsingGroup {
|
||||
if rule.IncludeModelName {
|
||||
if len(parts) < 3 {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if rule.IncludeUsingGroup {
|
||||
minParts := 3
|
||||
if rule.IncludeModelName {
|
||||
minParts = 4
|
||||
}
|
||||
if len(parts) < minParts {
|
||||
unknown++
|
||||
continue
|
||||
}
|
||||
}
|
||||
byRuleName[ruleName]++
|
||||
}
|
||||
|
||||
@@ -319,11 +329,14 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
|
||||
}
|
||||
}
|
||||
|
||||
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, usingGroup string, affinityValue string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
if rule.IncludeRuleName && rule.Name != "" {
|
||||
parts = append(parts, rule.Name)
|
||||
}
|
||||
if rule.IncludeModelName && modelName != "" {
|
||||
parts = append(parts, modelName)
|
||||
}
|
||||
if rule.IncludeUsingGroup && usingGroup != "" {
|
||||
parts = append(parts, usingGroup)
|
||||
}
|
||||
@@ -573,7 +586,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = setting.DefaultTTLSeconds
|
||||
}
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, usingGroup, affinityValue)
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
|
||||
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
|
||||
setChannelAffinityContext(c, channelAffinityMeta{
|
||||
CacheKey: cacheKeyFull,
|
||||
|
||||
@@ -193,7 +193,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
|
||||
require.NotNil(t, codexRule)
|
||||
|
||||
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "default", affinityValue)
|
||||
cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
|
||||
|
||||
cache := getChannelAffinityCache()
|
||||
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
|
||||
|
||||
@@ -28,6 +28,10 @@ var (
|
||||
codexCredentialRefreshRunning atomic.Bool
|
||||
)
|
||||
|
||||
func shouldAutoRefreshCodexChannelStatus(status int) bool {
|
||||
return status == common.ChannelStatusEnabled || status == common.ChannelStatusAutoDisabled
|
||||
}
|
||||
|
||||
func StartCodexCredentialAutoRefreshTask() {
|
||||
codexCredentialRefreshOnce.Do(func() {
|
||||
if !common.IsMasterNode {
|
||||
@@ -65,7 +69,11 @@ func runCodexCredentialAutoRefreshOnce() {
|
||||
var channels []*model.Channel
|
||||
err := model.DB.
|
||||
Select("id", "name", "key", "status", "channel_info").
|
||||
Where("type = ? AND status = 1", constant.ChannelTypeCodex).
|
||||
Where("type = ? AND (status = ? OR status = ?)",
|
||||
constant.ChannelTypeCodex,
|
||||
common.ChannelStatusEnabled,
|
||||
common.ChannelStatusAutoDisabled,
|
||||
).
|
||||
Order("id asc").
|
||||
Limit(codexCredentialRefreshBatchSize).
|
||||
Offset(offset).
|
||||
|
||||
+37
-15
@@ -227,21 +227,31 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||
if oaiUsage == nil {
|
||||
return nil
|
||||
}
|
||||
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
|
||||
oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
)
|
||||
usage := &dto.ClaudeUsage{
|
||||
InputTokens: oaiUsage.PromptTokens,
|
||||
OutputTokens: oaiUsage.CompletionTokens,
|
||||
CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens,
|
||||
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
|
||||
}
|
||||
if oaiUsage.ClaudeCacheCreation5mTokens > 0 || oaiUsage.ClaudeCacheCreation1hTokens > 0 {
|
||||
if cacheCreation5m > 0 || cacheCreation1h > 0 {
|
||||
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
|
||||
Ephemeral5mInputTokens: oaiUsage.ClaudeCacheCreation5mTokens,
|
||||
Ephemeral1hInputTokens: oaiUsage.ClaudeCacheCreation1hTokens,
|
||||
Ephemeral5mInputTokens: cacheCreation5m,
|
||||
Ephemeral1hInputTokens: cacheCreation1h,
|
||||
}
|
||||
}
|
||||
return usage
|
||||
}
|
||||
|
||||
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
|
||||
return tokens5m + remainder, tokens1h
|
||||
}
|
||||
|
||||
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse {
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
return nil
|
||||
@@ -426,23 +436,28 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
}
|
||||
|
||||
if len(openAIResponse.Choices) == 0 {
|
||||
// no choices
|
||||
// 可能为非标准的 OpenAI 响应,判断是否已经完成
|
||||
if info.ClaudeConvertInfo.Done {
|
||||
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
}
|
||||
if oaiUsage != nil {
|
||||
stopOpenBlocks()
|
||||
oaiUsage := info.ClaudeConvertInfo.Usage
|
||||
if oaiUsage != nil {
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)),
|
||||
},
|
||||
})
|
||||
stopReason := stopReasonOpenAI2Claude(info.FinishReason)
|
||||
if stopReason == "" {
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: common.GetPointer[string](stopReason),
|
||||
},
|
||||
})
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_stop",
|
||||
})
|
||||
info.ClaudeConvertInfo.Done = true
|
||||
}
|
||||
return claudeResponses
|
||||
} else {
|
||||
@@ -450,6 +465,13 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
|
||||
if doneChunk {
|
||||
info.FinishReason = *chosenChoice.FinishReason
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = info.ClaudeConvertInfo.Usage
|
||||
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
|
||||
// Defer closing until usage is available so the final message_delta carries it.
|
||||
return claudeResponses
|
||||
}
|
||||
}
|
||||
|
||||
var claudeResponse dto.ClaudeResponse
|
||||
|
||||
+53
-32
@@ -25,14 +25,26 @@ import (
|
||||
// FileService 统一的文件处理服务
|
||||
// 提供文件下载、解码、缓存等功能的统一入口
|
||||
|
||||
// getContextCacheKey 生成 context 缓存的 key
|
||||
// getContextCacheKey 生成 URL context 缓存的 key
|
||||
func getContextCacheKey(url string) string {
|
||||
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
|
||||
}
|
||||
|
||||
// getBase64ContextCacheKey 生成 base64 context 缓存的 key
|
||||
// 使用 length + MIME + 前 128 字符作为输入,避免对整个 base64 数据做 hash
|
||||
func getBase64ContextCacheKey(data string, mimeType string) string {
|
||||
keyMaterial := fmt.Sprintf("%d:%s:", len(data), mimeType)
|
||||
if len(data) > 128 {
|
||||
keyMaterial += data[:128]
|
||||
} else {
|
||||
keyMaterial += data
|
||||
}
|
||||
return fmt.Sprintf("b64_cache_%s", common.GenerateHMAC(keyMaterial))
|
||||
}
|
||||
|
||||
// LoadFileSource 加载文件源数据
|
||||
// 这是统一的入口,会自动处理缓存和不同的来源类型
|
||||
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
|
||||
func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) (*types.CachedFileData, error) {
|
||||
if source == nil {
|
||||
return nil, fmt.Errorf("file source is nil")
|
||||
}
|
||||
@@ -43,7 +55,6 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
|
||||
|
||||
// 1. 快速检查内部缓存
|
||||
if source.HasCache() {
|
||||
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
@@ -62,39 +73,49 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
|
||||
return source.GetCache(), nil
|
||||
}
|
||||
|
||||
// 4. 如果是 URL,检查 Context 缓存
|
||||
var contextKey string
|
||||
if source.IsURL() && c != nil {
|
||||
contextKey = getContextCacheKey(source.URL)
|
||||
if cachedData, exists := c.Get(contextKey); exists {
|
||||
data := cachedData.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 执行加载逻辑
|
||||
// 4. 根据来源类型加载(含 URL context 缓存查找)
|
||||
var cachedData *types.CachedFileData
|
||||
var contextKey string
|
||||
var err error
|
||||
|
||||
if source.IsURL() {
|
||||
cachedData, err = loadFromURL(c, source.URL, reason...)
|
||||
} else {
|
||||
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
|
||||
switch s := source.(type) {
|
||||
case *types.URLSource:
|
||||
if c != nil {
|
||||
contextKey = getContextCacheKey(s.URL)
|
||||
if cached, exists := c.Get(contextKey); exists {
|
||||
data := cached.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
cachedData, err = loadFromURL(c, s.URL, reason...)
|
||||
case *types.Base64Source:
|
||||
if c != nil {
|
||||
contextKey = getBase64ContextCacheKey(s.Base64Data, s.MimeType)
|
||||
if cached, exists := c.Get(contextKey); exists {
|
||||
data := cached.(*types.CachedFileData)
|
||||
source.SetCache(data)
|
||||
registerSourceForCleanup(c, source)
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
cachedData, err = loadFromBase64(s.Base64Data, s.MimeType)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported file source type: %T", source)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 6. 设置缓存
|
||||
// 5. 设置缓存
|
||||
source.SetCache(cachedData)
|
||||
if contextKey != "" && c != nil {
|
||||
c.Set(contextKey, cachedData)
|
||||
}
|
||||
|
||||
// 7. 注册到 context 以便请求结束时自动清理
|
||||
// 6. 注册到 context 以便请求结束时自动清理
|
||||
if c != nil {
|
||||
registerSourceForCleanup(c, source)
|
||||
}
|
||||
@@ -103,15 +124,15 @@ func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string)
|
||||
}
|
||||
|
||||
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
|
||||
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
|
||||
func registerSourceForCleanup(c *gin.Context, source types.FileSource) {
|
||||
if source.IsRegistered() {
|
||||
return
|
||||
}
|
||||
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
var sources []*types.FileSource
|
||||
var sources []types.FileSource
|
||||
if existing, exists := c.Get(key); exists {
|
||||
sources = existing.([]*types.FileSource)
|
||||
sources = existing.([]types.FileSource)
|
||||
}
|
||||
sources = append(sources, source)
|
||||
c.Set(key, sources)
|
||||
@@ -123,12 +144,12 @@ func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
|
||||
func CleanupFileSources(c *gin.Context) {
|
||||
key := string(constant.ContextKeyFileSourcesToCleanup)
|
||||
if sources, exists := c.Get(key); exists {
|
||||
for _, source := range sources.([]*types.FileSource) {
|
||||
for _, source := range sources.([]types.FileSource) {
|
||||
if cache := source.GetCache(); cache != nil {
|
||||
cache.Close()
|
||||
}
|
||||
}
|
||||
c.Set(key, nil) // 清除引用
|
||||
c.Set(key, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +384,7 @@ func loadFromBase64(base64String string, providedMimeType string) (*types.Cached
|
||||
}
|
||||
|
||||
// GetImageConfig 获取图片配置
|
||||
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
|
||||
func GetImageConfig(c *gin.Context, source types.FileSource) (image.Config, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, "get_image_config")
|
||||
if err != nil {
|
||||
return image.Config{}, "", err
|
||||
@@ -394,7 +415,7 @@ func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, str
|
||||
}
|
||||
|
||||
// GetBase64Data 获取 base64 编码的数据
|
||||
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
|
||||
func GetBase64Data(c *gin.Context, source types.FileSource, reason ...string) (string, string, error) {
|
||||
cachedData, err := LoadFileSource(c, source, reason...)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -407,13 +428,13 @@ func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (
|
||||
}
|
||||
|
||||
// GetMimeType 获取文件的 MIME 类型
|
||||
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
|
||||
func GetMimeType(c *gin.Context, source types.FileSource) (string, error) {
|
||||
if source.HasCache() {
|
||||
return source.GetCache().MimeType, nil
|
||||
}
|
||||
|
||||
if source.IsURL() {
|
||||
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
|
||||
if urlSource, ok := source.(*types.URLSource); ok {
|
||||
mimeType, err := GetFileTypeFromUrl(c, urlSource.URL, "get_mime_type")
|
||||
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
|
||||
return mimeType, nil
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (w *WalletFunding) PreConsume(amount int) error {
|
||||
if amount <= 0 {
|
||||
return nil
|
||||
}
|
||||
if err := model.DecreaseUserQuota(w.userId, amount); err != nil {
|
||||
if err := model.DecreaseUserQuota(w.userId, amount, false); err != nil {
|
||||
return err
|
||||
}
|
||||
w.consumed = amount
|
||||
@@ -49,7 +49,7 @@ func (w *WalletFunding) Settle(delta int) error {
|
||||
return nil
|
||||
}
|
||||
if delta > 0 {
|
||||
return model.DecreaseUserQuota(w.userId, delta)
|
||||
return model.DecreaseUserQuota(w.userId, delta, false)
|
||||
}
|
||||
return model.IncreaseUserQuota(w.userId, -delta, false)
|
||||
}
|
||||
|
||||
+1
-1
@@ -381,7 +381,7 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu
|
||||
} else {
|
||||
// Wallet
|
||||
if quota > 0 {
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota, false)
|
||||
} else {
|
||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||
}
|
||||
|
||||
+21
-5
@@ -36,8 +36,12 @@ func LogTaskConsumption(c *gin.Context, info *relaycommon.RelayInfo) {
|
||||
}
|
||||
}
|
||||
other := make(map[string]interface{})
|
||||
other["is_task"] = true
|
||||
other["request_path"] = c.Request.URL.Path
|
||||
other["model_price"] = info.PriceData.ModelPrice
|
||||
if info.PriceData.ModelRatio > 0 {
|
||||
other["model_ratio"] = info.PriceData.ModelRatio
|
||||
}
|
||||
other["group_ratio"] = info.PriceData.GroupRatioInfo.GroupRatio
|
||||
if info.PriceData.GroupRatioInfo.HasSpecialRatio {
|
||||
other["user_group_ratio"] = info.PriceData.GroupRatioInfo.GroupSpecialRatio
|
||||
@@ -86,7 +90,7 @@ func taskAdjustFunding(task *model.Task, delta int) error {
|
||||
return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta))
|
||||
}
|
||||
if delta > 0 {
|
||||
return model.DecreaseUserQuota(task.UserId, delta)
|
||||
return model.DecreaseUserQuota(task.UserId, delta, false)
|
||||
}
|
||||
return model.IncreaseUserQuota(task.UserId, -delta, false)
|
||||
}
|
||||
@@ -117,6 +121,9 @@ func taskBillingOther(task *model.Task) map[string]interface{} {
|
||||
other := make(map[string]interface{})
|
||||
if bc := task.PrivateData.BillingContext; bc != nil {
|
||||
other["model_price"] = bc.ModelPrice
|
||||
if bc.ModelRatio > 0 {
|
||||
other["model_ratio"] = bc.ModelRatio
|
||||
}
|
||||
other["group_ratio"] = bc.GroupRatio
|
||||
if len(bc.OtherRatios) > 0 {
|
||||
for k, v := range bc.OtherRatios {
|
||||
@@ -222,7 +229,6 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
|
||||
}
|
||||
other := taskBillingOther(task)
|
||||
other["task_id"] = task.TaskID
|
||||
//other["reason"] = reason
|
||||
other["pre_consumed_quota"] = preConsumedQuota
|
||||
other["actual_quota"] = actualQuota
|
||||
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
|
||||
@@ -277,9 +283,19 @@ func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTo
|
||||
finalGroupRatio = groupRatio
|
||||
}
|
||||
|
||||
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio
|
||||
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio)
|
||||
// 计算 OtherRatios 乘积(视频折扣、时长等)
|
||||
otherMultiplier := 1.0
|
||||
if bc := task.PrivateData.BillingContext; bc != nil {
|
||||
for _, r := range bc.OtherRatios {
|
||||
if r != 1.0 && r > 0 {
|
||||
otherMultiplier *= r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f", totalTokens, modelRatio, finalGroupRatio)
|
||||
// 计算实际应扣费额度: totalTokens * modelRatio * groupRatio * otherMultiplier
|
||||
actualQuota := int(float64(totalTokens) * modelRatio * finalGroupRatio * otherMultiplier)
|
||||
|
||||
reason := fmt.Sprintf("token重算:tokens=%d, modelRatio=%.2f, groupRatio=%.2f, otherMultiplier=%.4f", totalTokens, modelRatio, finalGroupRatio, otherMultiplier)
|
||||
RecalculateTaskQuota(ctx, task, actualQuota, reason)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user