Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ff8c7ab03 | |||
| c31343ac76 | |||
| b2e62a44ee | |||
| 9253426223 | |||
| 209d90e861 | |||
| e2807c5f95 | |||
| 283474020d | |||
| 47d7bca268 | |||
| dd57eeb514 | |||
| 22e509c1ef | |||
| 3cad6b9d7f | |||
| 8aaec8b1cc |
+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
|
||||
}
|
||||
|
||||
|
||||
@@ -830,7 +830,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)
|
||||
}
|
||||
|
||||
// 当错误检查通过,才检查响应时间
|
||||
|
||||
+1
-1
@@ -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())
|
||||
})
|
||||
|
||||
+6
-2
@@ -340,6 +340,10 @@ func EpayNotify(c *gin.Context) {
|
||||
log.Printf("易支付回调未找到订单: %v", verifyInfo)
|
||||
return
|
||||
}
|
||||
if topUp.PaymentMethod == "stripe" || topUp.PaymentMethod == "creem" || topUp.PaymentMethod == "waffo" {
|
||||
log.Printf("易支付回调订单支付方式不匹配: %s, 订单号: %s", topUp.PaymentMethod, verifyInfo.ServiceTradeNo)
|
||||
return
|
||||
}
|
||||
if topUp.Status == "pending" {
|
||||
topUp.Status = "success"
|
||||
err := topUp.Update()
|
||||
@@ -358,7 +362,7 @@ func EpayNotify(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
log.Printf("易支付回调更新用户成功 %v", topUp)
|
||||
model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money))
|
||||
model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, "epay")
|
||||
}
|
||||
} else {
|
||||
log.Printf("易支付异常回调: %v", verifyInfo)
|
||||
@@ -457,7 +461,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -108,12 +108,13 @@ 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: PaymentMethodCreem,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
err = topUp.Insert()
|
||||
if err != nil {
|
||||
@@ -352,7 +353,7 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
log.Printf("警告:Creem回调中客户姓名为空 - 订单号: %s", referenceId)
|
||||
}
|
||||
|
||||
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)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
|
||||
+16
-10
@@ -170,15 +170,16 @@ func StripeWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
callerIp := c.ClientIP()
|
||||
switch event.Type {
|
||||
case stripe.EventTypeCheckoutSessionCompleted:
|
||||
sessionCompleted(event)
|
||||
sessionCompleted(event, callerIp)
|
||||
case stripe.EventTypeCheckoutSessionExpired:
|
||||
sessionExpired(event)
|
||||
case stripe.EventTypeCheckoutSessionAsyncPaymentSucceeded:
|
||||
sessionAsyncPaymentSucceeded(event)
|
||||
sessionAsyncPaymentSucceeded(event, callerIp)
|
||||
case stripe.EventTypeCheckoutSessionAsyncPaymentFailed:
|
||||
sessionAsyncPaymentFailed(event)
|
||||
sessionAsyncPaymentFailed(event, callerIp)
|
||||
default:
|
||||
log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type)
|
||||
}
|
||||
@@ -186,7 +187,7 @@ func StripeWebhook(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func sessionCompleted(event stripe.Event) {
|
||||
func sessionCompleted(event stripe.Event, callerIp string) {
|
||||
customerId := event.GetObjectValue("customer")
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
status := event.GetObjectValue("status")
|
||||
@@ -201,22 +202,22 @@ func sessionCompleted(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
fulfillOrder(event, referenceId, customerId)
|
||||
fulfillOrder(event, referenceId, customerId, callerIp)
|
||||
}
|
||||
|
||||
// sessionAsyncPaymentSucceeded handles delayed payment methods (bank transfer, SEPA, etc.)
|
||||
// that confirm payment after the checkout session completes.
|
||||
func sessionAsyncPaymentSucceeded(event stripe.Event) {
|
||||
func sessionAsyncPaymentSucceeded(event stripe.Event, callerIp string) {
|
||||
customerId := event.GetObjectValue("customer")
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
log.Printf("Stripe 异步支付成功: %s", referenceId)
|
||||
|
||||
fulfillOrder(event, referenceId, customerId)
|
||||
fulfillOrder(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(event stripe.Event) {
|
||||
func sessionAsyncPaymentFailed(event stripe.Event, callerIp string) {
|
||||
referenceId := event.GetObjectValue("client_reference_id")
|
||||
log.Printf("Stripe 异步支付失败: %s", referenceId)
|
||||
|
||||
@@ -234,6 +235,11 @@ func sessionAsyncPaymentFailed(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != PaymentMethodStripe {
|
||||
log.Printf("异步支付失败,订单支付方式不匹配: %s, ref: %s", topUp.PaymentMethod, referenceId)
|
||||
return
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
log.Printf("异步支付失败,订单状态非pending: %s, ref: %s", topUp.Status, referenceId)
|
||||
return
|
||||
@@ -248,7 +254,7 @@ func sessionAsyncPaymentFailed(event stripe.Event) {
|
||||
}
|
||||
|
||||
// fulfillOrder is the shared logic for crediting quota after payment is confirmed.
|
||||
func fulfillOrder(event stripe.Event, referenceId string, customerId string) {
|
||||
func fulfillOrder(event stripe.Event, referenceId string, customerId string, callerIp string) {
|
||||
if len(referenceId) == 0 {
|
||||
log.Println("未提供支付单号")
|
||||
return
|
||||
@@ -269,7 +275,7 @@ func fulfillOrder(event stripe.Event, referenceId string, customerId string) {
|
||||
return
|
||||
}
|
||||
|
||||
err := model.Recharge(referenceId, customerId)
|
||||
err := model.Recharge(referenceId, customerId, callerIp)
|
||||
if err != nil {
|
||||
log.Println(err.Error(), referenceId)
|
||||
return
|
||||
|
||||
@@ -357,7 +357,7 @@ func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.Pa
|
||||
LockOrder(merchantOrderId)
|
||||
defer UnlockOrder(merchantOrderId)
|
||||
|
||||
if err := model.RechargeWaffo(merchantOrderId); err != nil {
|
||||
if err := model.RechargeWaffo(merchantOrderId, c.ClientIP()); err != nil {
|
||||
log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId)
|
||||
sendWaffoWebhookResponse(c, wh, false, err.Error())
|
||||
return
|
||||
|
||||
+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,
|
||||
|
||||
+28
-6
@@ -891,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)
|
||||
@@ -913,6 +918,11 @@ func ManageUser(c *gin.Context) {
|
||||
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 {
|
||||
@@ -923,8 +933,8 @@ func ManageUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)增加用户额度 %s", adminName, logger.LogQuota(req.Value)))
|
||||
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)
|
||||
@@ -934,16 +944,16 @@ func ManageUser(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)减少用户额度 %s", adminName, logger.LogQuota(req.Value)))
|
||||
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.RecordLog(user.Id, model.LogTypeManage,
|
||||
fmt.Sprintf("管理员(%s)覆盖用户额度从 %s 为 %s", adminName, logger.LogQuota(oldQuota), logger.LogQuota(req.Value)))
|
||||
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
|
||||
@@ -959,6 +969,18 @@ func ManageUser(c *gin.Context) {
|
||||
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,
|
||||
|
||||
@@ -448,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -90,6 +90,57 @@ 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(),
|
||||
"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))
|
||||
|
||||
@@ -480,3 +480,32 @@ func GetTokenKeysByIds(ids []int, userId int) ([]Token, error) {
|
||||
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
|
||||
}
|
||||
|
||||
+74
-32
@@ -12,17 +12,19 @@ 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"`
|
||||
}
|
||||
|
||||
var ErrPaymentMethodMismatch = errors.New("payment method mismatch")
|
||||
|
||||
func (topUp *TopUp) Insert() error {
|
||||
var err error
|
||||
err = DB.Create(topUp).Error
|
||||
@@ -55,7 +57,7 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp {
|
||||
return topUp
|
||||
}
|
||||
|
||||
func Recharge(referenceId string, customerId string) (err error) {
|
||||
func Recharge(referenceId string, customerId string, callerIp string) (err error) {
|
||||
if referenceId == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
@@ -74,6 +76,10 @@ func Recharge(referenceId string, customerId string) (err error) {
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != "stripe" {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
@@ -99,11 +105,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, "stripe")
|
||||
|
||||
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 +130,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 +154,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 +183,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 +199,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 +227,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 +241,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 +268,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 +281,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{}
|
||||
@@ -295,6 +328,7 @@ func ManualCompleteTopUp(tradeNo string) error {
|
||||
|
||||
userId = topUp.UserId
|
||||
payMoney = topUp.Money
|
||||
paymentMethod = topUp.PaymentMethod
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -303,10 +337,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 +359,10 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != "creem" {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
|
||||
if topUp.Status != common.TopUpStatusPending {
|
||||
return errors.New("充值订单状态错误")
|
||||
}
|
||||
@@ -372,12 +410,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, "creem")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RechargeWaffo(tradeNo string) (err error) {
|
||||
func RechargeWaffo(tradeNo string, callerIp string) (err error) {
|
||||
if tradeNo == "" {
|
||||
return errors.New("未提供支付单号")
|
||||
}
|
||||
@@ -396,6 +434,10 @@ func RechargeWaffo(tradeNo string) (err error) {
|
||||
return errors.New("充值订单不存在")
|
||||
}
|
||||
|
||||
if topUp.PaymentMethod != "waffo" {
|
||||
return ErrPaymentMethodMismatch
|
||||
}
|
||||
|
||||
if topUp.Status == common.TopUpStatusSuccess {
|
||||
return nil // 幂等:已成功直接返回
|
||||
}
|
||||
@@ -430,7 +472,7 @@ 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, "waffo")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -154,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 = nil
|
||||
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 = nil
|
||||
claudeRequest.Temperature = common.GetPointer[float64](1.0)
|
||||
if !model_setting.ShouldPreserveThinkingSuffix(textRequest.Model) {
|
||||
claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
claudeRequest.Model = trimmedModel
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,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)
|
||||
@@ -274,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),
|
||||
@@ -339,16 +360,22 @@ 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),
|
||||
})
|
||||
if mediaMessage.Text != "" {
|
||||
claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: common.GetPointer[string](mediaMessage.Text),
|
||||
})
|
||||
}
|
||||
default:
|
||||
source := mediaMessage.ToFileSource()
|
||||
if source == nil {
|
||||
|
||||
@@ -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"
|
||||
|
||||
+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")
|
||||
|
||||
+1
-36
@@ -42,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
|
||||
}
|
||||
@@ -58,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)
|
||||
|
||||
@@ -64,6 +64,13 @@ var defaultCacheRatio = map[string]float64{
|
||||
"claude-opus-4-6-high": 0.1,
|
||||
"claude-opus-4-6-medium": 0.1,
|
||||
"claude-opus-4-6-low": 0.1,
|
||||
"claude-opus-4-7": 0.1,
|
||||
"claude-opus-4-7-thinking": 0.1,
|
||||
"claude-opus-4-7-max": 0.1,
|
||||
"claude-opus-4-7-xhigh": 0.1,
|
||||
"claude-opus-4-7-high": 0.1,
|
||||
"claude-opus-4-7-medium": 0.1,
|
||||
"claude-opus-4-7-low": 0.1,
|
||||
}
|
||||
|
||||
var defaultCreateCacheRatio = map[string]float64{
|
||||
@@ -92,6 +99,13 @@ var defaultCreateCacheRatio = map[string]float64{
|
||||
"claude-opus-4-6-high": 1.25,
|
||||
"claude-opus-4-6-medium": 1.25,
|
||||
"claude-opus-4-6-low": 1.25,
|
||||
"claude-opus-4-7": 1.25,
|
||||
"claude-opus-4-7-thinking": 1.25,
|
||||
"claude-opus-4-7-max": 1.25,
|
||||
"claude-opus-4-7-xhigh": 1.25,
|
||||
"claude-opus-4-7-high": 1.25,
|
||||
"claude-opus-4-7-medium": 1.25,
|
||||
"claude-opus-4-7-low": 1.25,
|
||||
}
|
||||
|
||||
//var defaultCreateCacheRatio = map[string]float64{}
|
||||
|
||||
@@ -146,6 +146,12 @@ var defaultModelRatio = map[string]float64{
|
||||
"claude-opus-4-6-high": 2.5,
|
||||
"claude-opus-4-6-medium": 2.5,
|
||||
"claude-opus-4-6-low": 2.5,
|
||||
"claude-opus-4-7": 2.5,
|
||||
"claude-opus-4-7-max": 2.5,
|
||||
"claude-opus-4-7-xhigh": 2.5,
|
||||
"claude-opus-4-7-high": 2.5,
|
||||
"claude-opus-4-7-medium": 2.5,
|
||||
"claude-opus-4-7-low": 2.5,
|
||||
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
|
||||
"claude-opus-4-20250514": 7.5,
|
||||
"claude-opus-4-1-20250805": 7.5,
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
var EffortSuffixes = []string{"-max", "-high", "-medium", "-low", "-minimal"}
|
||||
var EffortSuffixes = []string{"-max", "-xhigh", "-high", "-medium", "-low", "-minimal"}
|
||||
|
||||
// TrimEffortSuffix -> modelName level(low) exists
|
||||
func TrimEffortSuffix(modelName string) (string, string, bool) {
|
||||
|
||||
@@ -876,7 +876,12 @@ export const getLogsColumns = ({
|
||||
),
|
||||
dataIndex: 'ip',
|
||||
render: (text, record, index) => {
|
||||
return (record.type === 2 || record.type === 5) && text ? (
|
||||
const showIp =
|
||||
(record.type === 2 ||
|
||||
record.type === 5 ||
|
||||
(isAdminUser && record.type === 1)) &&
|
||||
text;
|
||||
return showIp ? (
|
||||
<Tooltip content={text}>
|
||||
<span>
|
||||
<Tag
|
||||
|
||||
+73
-2
@@ -695,13 +695,13 @@ export const useLogsData = () => {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isAdminUser && logs[i].type !== 6) {
|
||||
if (isAdminUser && logs[i].type !== 6 && logs[i].type !== 1) {
|
||||
expandDataLocal.push({
|
||||
key: t('请求转换'),
|
||||
value: requestConversionDisplayValue(other?.request_conversion),
|
||||
});
|
||||
}
|
||||
if (isAdminUser && logs[i].type !== 6) {
|
||||
if (isAdminUser && logs[i].type !== 6 && logs[i].type !== 1) {
|
||||
let localCountMode = '';
|
||||
if (other?.admin_info?.local_count_tokens) {
|
||||
localCountMode = t('本地计费');
|
||||
@@ -713,6 +713,77 @@ export const useLogsData = () => {
|
||||
value: localCountMode,
|
||||
});
|
||||
}
|
||||
if (isAdminUser && logs[i].type === 1) {
|
||||
const adminInfo = other?.admin_info;
|
||||
if (adminInfo) {
|
||||
if (adminInfo.payment_method) {
|
||||
expandDataLocal.push({
|
||||
key: t('订单支付方式'),
|
||||
value: adminInfo.payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.callback_payment_method) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调支付方式'),
|
||||
value: adminInfo.callback_payment_method,
|
||||
});
|
||||
}
|
||||
if (adminInfo.caller_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('回调调用者IP'),
|
||||
value: adminInfo.caller_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.server_ip) {
|
||||
expandDataLocal.push({
|
||||
key: t('服务器IP'),
|
||||
value: adminInfo.server_ip,
|
||||
});
|
||||
}
|
||||
if (adminInfo.version) {
|
||||
expandDataLocal.push({
|
||||
key: t('系统版本'),
|
||||
value: adminInfo.version,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
expandDataLocal.push({
|
||||
key: t('审计信息'),
|
||||
value: (
|
||||
<span style={{ color: 'var(--semi-color-warning)' }}>
|
||||
{t(
|
||||
'该记录由旧版本实例写入,缺少审计信息,建议将实例升级至最新版本以便记录服务器IP、回调IP、支付方式与系统版本等审计字段。',
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (isAdminUser && logs[i].type === 3 && other?.admin_info) {
|
||||
const adminInfo = other.admin_info;
|
||||
const hasUsername =
|
||||
adminInfo.admin_username !== undefined &&
|
||||
adminInfo.admin_username !== null &&
|
||||
adminInfo.admin_username !== '';
|
||||
const hasId =
|
||||
adminInfo.admin_id !== undefined &&
|
||||
adminInfo.admin_id !== null &&
|
||||
adminInfo.admin_id !== '';
|
||||
if (hasUsername || hasId) {
|
||||
let operatorValue = '';
|
||||
if (hasUsername && hasId) {
|
||||
operatorValue = `${adminInfo.admin_username} (ID: ${adminInfo.admin_id})`;
|
||||
} else if (hasUsername) {
|
||||
operatorValue = String(adminInfo.admin_username);
|
||||
} else {
|
||||
operatorValue = `ID: ${adminInfo.admin_id}`;
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('操作管理员'),
|
||||
value: operatorValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
expandDatesLocal[logs[i].key] = expandDataLocal;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+346
-193
File diff suppressed because it is too large
Load Diff
Vendored
+371
-205
File diff suppressed because it is too large
Load Diff
Vendored
+345
-191
File diff suppressed because it is too large
Load Diff
Vendored
+377
-205
File diff suppressed because it is too large
Load Diff
Vendored
+346
-195
File diff suppressed because it is too large
Load Diff
Vendored
+1055
-438
File diff suppressed because it is too large
Load Diff
Vendored
+658
-165
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user