diff --git a/AGENTS.md b/AGENTS.md index 1e7d202..656a66a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,3 +134,13 @@ For request structs that are parsed from client JSON and then re-marshaled to up ### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md` When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document. + +### Rule 8: Process Documents — Keep Them Under `.tmp/` + +All non-project process documents MUST be created and kept under `.tmp/`. + +This includes investigation notes, review outputs, scratch plans, working drafts, temporary release-note or PR-copy drafts, one-off deployment notes, private operation notes, temporary diff or patch files, and any document that records internal work process rather than public project documentation. + +Do NOT place these files in the repository root, `docs/`, `.github/`, or source directories unless the content is explicitly intended to be public project documentation. `.tmp/` is ignored by Git; use it to prevent accidental publication of sensitive or internal process information to the open-source GitHub repository. If process documents are found outside `.tmp/`, move them into `.tmp/` before committing. + +Project-owned agent workflows and reusable skill instructions, such as files under `.agents/skills/`, are part of the project tooling contract and MUST remain in the tracked project tree unless explicitly retired. diff --git a/CLAUDE.md b/CLAUDE.md index 23777e7..7aca2bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,3 +134,13 @@ For request structs that are parsed from client JSON and then re-marshaled to up ### Rule 7: Billing Expression System — Read `pkg/billingexpr/expr.md` When working on tiered/dynamic billing (expression-based pricing), you MUST read `pkg/billingexpr/expr.md` first. It documents the design philosophy, expression language (variables, functions, examples), full system architecture (editor → storage → pre-consume → settlement → log display), token normalization rules (`p`/`c` auto-exclusion), quota conversion, and expression versioning. All code changes to the billing expression system must follow the patterns described in that document. + +### Rule 8: Process Documents — Keep Them Under `.tmp/` + +All non-project process documents MUST be created and kept under `.tmp/`. + +This includes investigation notes, review outputs, scratch plans, working drafts, temporary release-note or PR-copy drafts, one-off deployment notes, private operation notes, temporary diff or patch files, and any document that records internal work process rather than public project documentation. + +Do NOT place these files in the repository root, `docs/`, `.github/`, or source directories unless the content is explicitly intended to be public project documentation. `.tmp/` is ignored by Git; use it to prevent accidental publication of sensitive or internal process information to the open-source GitHub repository. If process documents are found outside `.tmp/`, move them into `.tmp/` before committing. + +Project-owned agent workflows and reusable skill instructions, such as files under `.agents/skills/`, are part of the project tooling contract and MUST remain in the tracked project tree unless explicitly retired. diff --git a/fix_quota.diff b/fix_quota.diff deleted file mode 100644 index b946942..0000000 --- a/fix_quota.diff +++ /dev/null @@ -1,414 +0,0 @@ -diff --git a/model/redemption.go b/model/redemption.go -index 5d3a0bc..629bbe4 100644 ---- a/model/redemption.go -+++ b/model/redemption.go -@@ -127,25 +127,43 @@ func Redeem(key string, userId int) (quota int, err error) { - } - common.RandomSleep() - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error -+ err := withRowLock(tx).Where(keyCol+" = ?", key).First(redemption).Error - if err != nil { - return errors.New("无效的兑换码") - } - if redemption.Status != common.RedemptionCodeStatusEnabled { - return errors.New("该兑换码已被使用") - } -- if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { -+ now := common.GetTimestamp() -+ if redemption.ExpiredTime != 0 && redemption.ExpiredTime < now { - return errors.New("该兑换码已过期") - } -- err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error -- if err != nil { -+ if redemption.Quota <= 0 { -+ return errors.New("无效的兑换额度") -+ } -+ result := tx.Model(&Redemption{}). -+ Where("id = ? AND status = ? AND (expired_time = 0 OR expired_time >= ?)", redemption.Id, common.RedemptionCodeStatusEnabled, now). -+ Updates(map[string]interface{}{ -+ "redeemed_time": now, -+ "status": common.RedemptionCodeStatusUsed, -+ "used_user_id": userId, -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("该兑换码已被使用") -+ } -+ -+ userResult := tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)) -+ if err := ensureUserUpdateMatchedTx(tx, userResult, userId, errors.New("无效的 user id")); err != nil { - return err - } -- redemption.RedeemedTime = common.GetTimestamp() -+ redemption.RedeemedTime = now - redemption.Status = common.RedemptionCodeStatusUsed - redemption.UsedUserId = userId -- err = tx.Save(redemption).Error -- return err -+ quota = redemption.Quota -+ return nil - }) - if err != nil { - common.SysError("redemption failed: " + err.Error()) -diff --git a/model/topup.go b/model/topup.go -index 274b707..c5155e9 100644 ---- a/model/topup.go -+++ b/model/topup.go -@@ -47,6 +47,45 @@ var ( - ErrTopUpStatusInvalid = errors.New("topup status invalid") - ) - -+func updatePendingTopUpStatusTx(tx *gorm.DB, topUp *TopUp, targetStatus string) error { -+ if tx == nil || topUp == nil || topUp.Id == 0 { -+ return ErrTopUpNotFound -+ } -+ result := tx.Model(&TopUp{}). -+ Where("id = ? AND status = ?", topUp.Id, common.TopUpStatusPending). -+ Update("status", targetStatus) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return ErrTopUpStatusInvalid -+ } -+ topUp.Status = targetStatus -+ return nil -+} -+ -+func markPendingTopUpSuccessTx(tx *gorm.DB, topUp *TopUp) error { -+ if tx == nil || topUp == nil || topUp.Id == 0 { -+ return ErrTopUpNotFound -+ } -+ now := common.GetTimestamp() -+ result := tx.Model(&TopUp{}). -+ Where("id = ? AND status = ?", topUp.Id, common.TopUpStatusPending). -+ Updates(map[string]interface{}{ -+ "status": common.TopUpStatusSuccess, -+ "complete_time": now, -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return ErrTopUpStatusInvalid -+ } -+ topUp.Status = common.TopUpStatusSuccess -+ topUp.CompleteTime = now -+ return nil -+} -+ - func (topUp *TopUp) Insert() error { - var err error - err = DB.Create(topUp).Error -@@ -91,7 +130,7 @@ func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, ta - - 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 { -+ if err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { - return ErrTopUpNotFound - } - if expectedPaymentProvider != "" && topUp.PaymentProvider != expectedPaymentProvider { -@@ -101,8 +140,7 @@ func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, ta - return ErrTopUpStatusInvalid - } - -- topUp.Status = targetStatus -- return tx.Save(topUp).Error -+ return updatePendingTopUpStatusTx(tx, topUp, targetStatus) - }) - } - -@@ -120,7 +158,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", referenceId).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -133,16 +171,16 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error - return errors.New("充值订单状态错误") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- err = tx.Save(topUp).Error -- if err != nil { -+ quota = topUp.Money * common.QuotaPerUnit -+ if quota <= 0 { -+ return errors.New("无效的充值额度") -+ } -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - -- quota = topUp.Money * common.QuotaPerUnit -- err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error -- if err != nil { -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}) -+ if err := ensureUserUpdateMatchedTx(tx, result, topUp.UserId, errors.New("充值用户不存在")); err != nil { - return err - } - -@@ -335,7 +373,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { - err := 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 { -+ if err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { - return errors.New("充值订单不存在") - } - -@@ -363,15 +401,13 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { - return errors.New("无效的充值额度") - } - -- // 标记完成 -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- if err := tx.Save(topUp).Error; err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - - // 增加用户额度(立即写库,保持一致性) -- if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)) -+ if err := ensureUserUpdateMatchedTx(tx, result, topUp.UserId, errors.New("充值用户不存在")); err != nil { - return err - } - -@@ -403,7 +439,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", referenceId).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -416,15 +452,14 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string - return errors.New("充值订单状态错误") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- err = tx.Save(topUp).Error -- if err != nil { -- return err -- } -- - // Creem 直接使用 Amount 作为充值额度(整数) - quota = topUp.Amount -+ if quota <= 0 { -+ return errors.New("无效的充值额度") -+ } -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { -+ return err -+ } - - // 构建更新字段,优先使用邮箱,如果邮箱为空则使用用户名 - updateFields := map[string]interface{}{ -@@ -446,8 +481,8 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string - } - } - -- err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error -- if err != nil { -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields) -+ if err := ensureUserUpdateMatchedTx(tx, result, topUp.UserId, errors.New("充值用户不存在")); err != nil { - return err - } - -@@ -478,7 +513,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -502,13 +537,12 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { - return errors.New("无效的充值额度") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- if err := tx.Save(topUp).Error; err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - -- if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)) -+ if err := ensureUserUpdateMatchedTx(tx, result, topUp.UserId, errors.New("充值用户不存在")); err != nil { - return err - } - -@@ -541,7 +575,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -563,13 +597,12 @@ func RechargeWaffoPancake(tradeNo string) (err error) { - return errors.New("无效的充值额度") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- if err := tx.Save(topUp).Error; err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - -- if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)) -+ if err := ensureUserUpdateMatchedTx(tx, result, topUp.UserId, errors.New("充值用户不存在")); err != nil { - return err - } - -diff --git a/model/user.go b/model/user.go -index b82ed09..2d8e4f5 100644 ---- a/model/user.go -+++ b/model/user.go -@@ -226,38 +226,31 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err - return users, total, nil - } - --func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) { -- var users []*User -- var total int64 -- var err error -- -- // 开始事务 -- tx := DB.Begin() -- if tx.Error != nil { -- return nil, 0, tx.Error -- } -- defer func() { -- if r := recover(); r != nil { -- tx.Rollback() -- } -- }() -+const ( -+ UserQuotaStatusNegative = "negative" -+ UserQuotaStatusZero = "zero" -+ UserQuotaStatusPositive = "positive" -+) - -- // 构建基础查询 -+func buildSearchUsersQuery(tx *gorm.DB, keyword string, group string, role *int, status *int, quotaStatus string) *gorm.DB { - query := tx.Unscoped().Model(&User{}) - -- // 构建搜索条件 -- likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" -- likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"} -+ keyword = strings.TrimSpace(keyword) -+ if keyword != "" { -+ // 构建搜索条件 -+ likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" -+ likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"} - -- // 尝试将关键字转换为整数ID -- keywordInt, err := strconv.Atoi(keyword) -- if err == nil { -- // 如果是数字,同时搜索ID和其他字段 -- likeCondition = "id = ? OR " + likeCondition -- likeArgs = append([]interface{}{keywordInt}, likeArgs...) -- } -+ // 尝试将关键字转换为整数ID -+ keywordInt, err := strconv.Atoi(keyword) -+ if err == nil { -+ // 如果是数字,同时搜索ID和其他字段 -+ likeCondition = "id = ? OR " + likeCondition -+ likeArgs = append([]interface{}{keywordInt}, likeArgs...) -+ } - -- query = query.Where("("+likeCondition+")", likeArgs...) -+ query = query.Where("("+likeCondition+")", likeArgs...) -+ } - if group != "" { - query = query.Where(commonGroupCol+" = ?", group) - } -@@ -271,6 +264,36 @@ func SearchUsers(keyword string, group string, role *int, status *int, startIdx - query = query.Where("deleted_at IS NULL").Where("status = ?", *status) - } - } -+ switch strings.ToLower(strings.TrimSpace(quotaStatus)) { -+ case UserQuotaStatusNegative: -+ query = query.Where("quota < 0") -+ case UserQuotaStatusZero: -+ query = query.Where("quota = 0") -+ case UserQuotaStatusPositive: -+ query = query.Where("quota > 0") -+ } -+ -+ return query -+} -+ -+func SearchUsers(keyword string, group string, role *int, status *int, quotaStatus string, startIdx int, num int) ([]*User, int64, error) { -+ var users []*User -+ var total int64 -+ var err error -+ -+ // 开始事务 -+ tx := DB.Begin() -+ if tx.Error != nil { -+ return nil, 0, tx.Error -+ } -+ defer func() { -+ if r := recover(); r != nil { -+ tx.Rollback() -+ } -+ }() -+ -+ // 构建基础查询 -+ query := buildSearchUsersQuery(tx, keyword, group, role, status, quotaStatus) - - // 获取总数 - err = query.Count(&total).Error -@@ -360,7 +383,7 @@ func (user *User) TransferAffQuotaToQuota(quota int) error { - defer tx.Rollback() // 确保在函数退出时事务能回滚 - - // 加锁查询用户以确保数据一致性 -- err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, user.Id).Error -+ err := withRowLock(tx).First(user, user.Id).Error - if err != nil { - return err - } -@@ -371,14 +394,20 @@ func (user *User) TransferAffQuotaToQuota(quota int) error { - } - - // 更新用户额度 -+ result := tx.Model(&User{}).Where("id = ? AND aff_quota >= ?", user.Id, quota). -+ Updates(map[string]interface{}{ -+ "aff_quota": gorm.Expr("aff_quota - ?", quota), -+ "quota": gorm.Expr("quota + ?", quota), -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("邀请额度不足!") -+ } - user.AffQuota -= quota - user.Quota += quota - -- // 保存用户状态 -- if err := tx.Save(user).Error; err != nil { -- return err -- } -- - // 提交事务 - return tx.Commit().Error - } diff --git a/fix_relay.diff b/fix_relay.diff deleted file mode 100644 index 0d95b6b..0000000 --- a/fix_relay.diff +++ /dev/null @@ -1,414 +0,0 @@ -diff --git a/controller/redemption.go b/controller/redemption.go -index 6c990bb..56eb572 100644 ---- a/controller/redemption.go -+++ b/controller/redemption.go -@@ -80,6 +80,10 @@ func AddRedemption(c *gin.Context) { - common.ApiErrorI18n(c, i18n.MsgRedemptionCountPositive) - return - } -+ if redemption.Quota <= 0 { -+ common.ApiErrorI18n(c, i18n.MsgRedemptionQuotaPositive) -+ return -+ } - if redemption.Count > 100 { - common.ApiErrorI18n(c, i18n.MsgRedemptionCountMax) - return -@@ -152,6 +156,10 @@ func UpdateRedemption(c *gin.Context) { - return - } - if statusOnly == "" { -+ if redemption.Quota <= 0 { -+ common.ApiErrorI18n(c, i18n.MsgRedemptionQuotaPositive) -+ return -+ } - if valid, msg := validateExpiredTime(c, redemption.ExpiredTime); !valid { - c.JSON(http.StatusOK, gin.H{"success": false, "message": msg}) - return -diff --git a/controller/relay.go b/controller/relay.go -index e005af8..93e146a 100644 ---- a/controller/relay.go -+++ b/controller/relay.go -@@ -341,6 +341,13 @@ func shouldRetry(c *gin.Context, openaiErr *types.MaxAPIError, retryTimes int) b - if _, ok := c.Get("specific_channel_id"); ok { - return false - } -+ errorCode := openaiErr.GetErrorCode() -+ if operation_setting.IsAlwaysSkipRetryCode(errorCode) { -+ return false -+ } -+ if errorCode == types.ErrorCodeEmptyResponse && !common.EmptyCompletionRetryEnabled { -+ return false -+ } - code := openaiErr.StatusCode - if code >= 200 && code < 300 { - return false -@@ -348,9 +355,6 @@ func shouldRetry(c *gin.Context, openaiErr *types.MaxAPIError, retryTimes int) b - if code < 100 || code > 599 { - return true - } -- if operation_setting.IsAlwaysSkipRetryCode(openaiErr.GetErrorCode()) { -- return false -- } - return operation_setting.ShouldRetryByStatusCode(code) - } - -diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go -index 7d67451..3500624 100644 ---- a/relay/channel/openai/chat_via_responses.go -+++ b/relay/channel/openai/chat_via_responses.go -@@ -64,6 +64,14 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - if err != nil { - return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) - } -+ emptyCompletion := isEmptyResponsesCompletion(&responsesResp) -+ if emptyCompletion { -+ willRetry := shouldRetryEmptyCompletion(c, info) -+ recordEmptyCompletion(c, info, usage, "empty_responses_output", responsesOutputTypes(&responsesResp), willRetry) -+ if willRetry { -+ return nil, newEmptyCompletionRetryError() -+ } -+ } - if usage == nil || usage.TotalTokens == 0 { - outputAuditText := service.ExtractOutputTextFromResponses(&responsesResp) - usage = service.ResponseText2Usage(c, outputAuditText, info.UpstreamModelName, info.GetEstimatePromptTokens()) -@@ -74,6 +82,9 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - } else if service.ResponseAuditEnabled() { - service.SetRelayResponseAuditContent(info, service.ExtractOutputTextFromResponses(&responsesResp)) - } -+ if !emptyCompletion { -+ recordEmptyCompletionRetrySuccess(c, info, usage) -+ } - - var responseBody []byte - switch info.RelayFormat { -@@ -106,13 +117,16 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - model := info.UpstreamModelName - - var ( -- usage = &dto.Usage{} -- outputText strings.Builder -- usageText strings.Builder -- sentStart bool -- sentStop bool -- sawToolCall bool -- streamErr *types.MaxAPIError -+ usage = &dto.Usage{} -+ outputText strings.Builder -+ usageText strings.Builder -+ pendingTextPrefix strings.Builder -+ sentStart bool -+ sentStop bool -+ sawToolCall bool -+ hasVisiblePayload bool -+ emptyCompletionRecorded bool -+ streamErr *types.MaxAPIError - ) - - toolCallIndexByID := make(map[string]int) -@@ -194,7 +208,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - //} - - sendReasoningSummaryDelta := func(delta string) bool { -- if delta == "" { -+ if strings.TrimSpace(delta) == "" { - return true - } - if needsReasoningSummarySeparator { -@@ -231,6 +245,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - return false - } - hasSentReasoningSummary = true -+ hasVisiblePayload = true - return true - } - -@@ -289,6 +304,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - return false - } - sawToolCall = true -+ hasVisiblePayload = true - - // Include tool call data in the local builder for fallback token estimation. - if tool.Function.Name != "" { -@@ -362,15 +378,23 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - // } - - case "response.output_text.delta": -- if !sendStartIfNeeded() { -- sr.Stop(streamErr) -- return -- } -- - if streamResp.Delta != "" { -- outputText.WriteString(streamResp.Delta) -- usageText.WriteString(streamResp.Delta) -+ if outputText.Len() == 0 && strings.TrimSpace(streamResp.Delta) == "" { -+ pendingTextPrefix.WriteString(streamResp.Delta) -+ break -+ } - delta := streamResp.Delta -+ if pendingTextPrefix.Len() > 0 { -+ delta = pendingTextPrefix.String() + delta -+ pendingTextPrefix.Reset() -+ } -+ if !sendStartIfNeeded() { -+ sr.Stop(streamErr) -+ return -+ } -+ outputText.WriteString(delta) -+ usageText.WriteString(delta) -+ hasVisiblePayload = true - chunk := &dto.ChatCompletionsStreamResponse{ - Id: responseId, - Object: "chat.completion.chunk", -@@ -447,7 +471,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - case "response.function_call_arguments.done": - - case "response.completed": -+ completedOutputTypes := []string(nil) - if streamResp.Response != nil { -+ completedOutputTypes = responsesOutputTypes(streamResp.Response) - if streamResp.Response.Model != "" { - model = streamResp.Response.Model - } -@@ -477,6 +503,65 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens - } - } -+ if outputText.Len() == 0 { -+ completedText := service.ExtractOutputTextFromResponses(streamResp.Response) -+ if strings.TrimSpace(completedText) != "" { -+ if !sendStartIfNeeded() { -+ sr.Stop(streamErr) -+ return -+ } -+ outputText.WriteString(completedText) -+ usageText.WriteString(completedText) -+ delta := completedText -+ chunk := &dto.ChatCompletionsStreamResponse{ -+ Id: responseId, -+ Object: "chat.completion.chunk", -+ Created: createAt, -+ Model: model, -+ Choices: []dto.ChatCompletionsStreamResponseChoice{ -+ { -+ Index: 0, -+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ -+ Content: &delta, -+ }, -+ }, -+ }, -+ } -+ if !sendChatChunk(chunk) { -+ sr.Stop(streamErr) -+ return -+ } -+ hasVisiblePayload = true -+ } -+ } -+ if !sawToolCall { -+ for _, out := range streamResp.Response.Output { -+ if out.Type != "function_call" { -+ continue -+ } -+ itemID := strings.TrimSpace(out.ID) -+ callID := strings.TrimSpace(out.CallId) -+ if callID == "" { -+ callID = itemID -+ } -+ if !sendToolCallDelta(callID, strings.TrimSpace(out.Name), out.ArgumentsString()) { -+ sr.Stop(streamErr) -+ return -+ } -+ } -+ } -+ if streamResp.Response.HasImageGenerationCall() { -+ hasVisiblePayload = true -+ } -+ } -+ -+ if !hasVisiblePayload { -+ emptyCompletionRecorded = true -+ if retryErr := handleEmptyResponsesStreamCompletion(c, info, usage, "empty_responses_stream_output", completedOutputTypes); retryErr != nil { -+ streamErr = retryErr -+ sr.Stop(streamErr) -+ return -+ } - } - - if !sendStartIfNeeded() { -@@ -518,6 +603,12 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - if streamErr != nil { - return nil, streamErr - } -+ if !hasVisiblePayload && !emptyCompletionRecorded { -+ emptyCompletionRecorded = true -+ if retryErr := handleEmptyResponsesStreamCompletion(c, info, usage, "empty_responses_stream_eof", nil); retryErr != nil { -+ return nil, retryErr -+ } -+ } - - if usage.TotalTokens == 0 { - usage = service.ResponseText2Usage(c, usageText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) -@@ -557,5 +648,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - if service.ResponseAuditEnabled() { - service.SetRelayResponseAuditContent(info, auditText) - } -+ if hasVisiblePayload { -+ recordEmptyCompletionRetrySuccess(c, info, usage) -+ } - return usage, nil - } -diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go -index 0be133d..2b160e3 100644 ---- a/relay/channel/openai/relay_responses.go -+++ b/relay/channel/openai/relay_responses.go -@@ -17,6 +17,8 @@ import ( - "github.com/gin-gonic/gin" - ) - -+const maxPendingResponsesStreamEvents = 32 -+ - func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.MaxAPIError) { - defer service.CloseResponseBodyGracefully(resp) - -@@ -43,9 +45,6 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http - c.Set("image_generation_call_size", responsesResponse.GetSize()) - } - -- // 写入新的 response body -- service.IOCopyBytesGracefully(c, resp, responseBody) -- - // compute usage - usage := dto.Usage{} - if responsesResponse.Usage != nil { -@@ -56,6 +55,20 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http - usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens - } - } -+ emptyCompletion := isEmptyResponsesCompletion(&responsesResponse) -+ if emptyCompletion { -+ willRetry := shouldRetryEmptyCompletion(c, info) -+ recordEmptyCompletion(c, info, &usage, "empty_responses_output", responsesOutputTypes(&responsesResponse), willRetry) -+ if willRetry { -+ return nil, newEmptyCompletionRetryError() -+ } -+ } -+ if !emptyCompletion { -+ recordEmptyCompletionRetrySuccess(c, info, &usage) -+ } -+ -+ service.IOCopyBytesGracefully(c, resp, responseBody) -+ - if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil { - return &usage, nil - } -@@ -81,8 +94,32 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - - var usage = &dto.Usage{} - var responseTextBuilder strings.Builder -+ var streamErr *types.MaxAPIError -+ type pendingResponsesStreamEvent struct { -+ response dto.ResponsesStreamResponse -+ data string -+ } -+ pendingEvents := make([]pendingResponsesStreamEvent, 0, 4) -+ streamForwarded := false -+ hasVisiblePayload := false -+ emptyCompletionRecorded := false -+ -+ flushPendingEvents := func() { -+ for _, event := range pendingEvents { -+ sendResponsesStreamData(c, event.response, event.data) -+ } -+ pendingEvents = pendingEvents[:0] -+ streamForwarded = true -+ } -+ shouldBufferForEmptyRetry := func() bool { -+ return !streamForwarded && shouldRetryEmptyCompletion(c, info) -+ } - - helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { -+ if streamErr != nil { -+ sr.Stop(streamErr) -+ return -+ } - - // 检查当前数据是否包含 completed 状态和 usage 信息 - var streamResponse dto.ResponsesStreamResponse -@@ -91,7 +128,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - sr.Error(err) - return - } -- sendResponsesStreamData(c, streamResponse, data) -+ if responsesStreamHasVisibleOutput(&streamResponse) { -+ hasVisiblePayload = true -+ } - switch streamResponse.Type { - case "response.completed": - if streamResponse.Response != nil { -@@ -115,6 +154,14 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - c.Set("image_generation_call_size", streamResponse.Response.GetSize()) - } - } -+ if !hasVisiblePayload { -+ emptyCompletionRecorded = true -+ if retryErr := handleEmptyResponsesStreamCompletion(c, info, usage, "empty_responses_stream_output", responsesOutputTypes(streamResponse.Response)); retryErr != nil { -+ streamErr = retryErr -+ sr.Stop(streamErr) -+ return -+ } -+ } - case "response.output_text.delta": - // 处理输出文本 - responseTextBuilder.WriteString(streamResponse.Delta) -@@ -131,8 +178,39 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - } - } - } -+ -+ if streamForwarded { -+ sendResponsesStreamData(c, streamResponse, data) -+ return -+ } -+ -+ pendingEvents = append(pendingEvents, pendingResponsesStreamEvent{ -+ response: streamResponse, -+ data: data, -+ }) -+ switch streamResponse.Type { -+ case "response.completed", "response.error", "response.failed": -+ flushPendingEvents() -+ default: -+ if hasVisiblePayload || len(pendingEvents) >= maxPendingResponsesStreamEvents || !shouldBufferForEmptyRetry() { -+ flushPendingEvents() -+ } -+ } - }) - -+ if streamErr != nil { -+ return nil, streamErr -+ } -+ if !hasVisiblePayload && !emptyCompletionRecorded { -+ emptyCompletionRecorded = true -+ if retryErr := handleEmptyResponsesStreamCompletion(c, info, usage, "empty_responses_stream_eof", nil); retryErr != nil { -+ return nil, retryErr -+ } -+ } -+ if !streamForwarded && len(pendingEvents) > 0 { -+ flushPendingEvents() -+ } -+ - if usage.CompletionTokens == 0 { - // 计算输出文本的 token 数量 - tempStr := responseTextBuilder.String() -@@ -151,6 +229,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - if service.ResponseAuditEnabled() { - service.SetRelayResponseAuditContent(info, responseTextBuilder.String()) - } -+ if hasVisiblePayload { -+ recordEmptyCompletionRetrySuccess(c, info, usage) -+ } - - return usage, nil - } diff --git a/model/log_test.go b/model/log_test.go index 79bec38..d48e1d6 100644 --- a/model/log_test.go +++ b/model/log_test.go @@ -47,7 +47,9 @@ func TestGetUserLogsRetryFilter(t *testing.T) { require.NoError(t, err) require.EqualValues(t, 2, total) require.Len(t, got, 2) - require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id}, []int{got[0].LogId, got[1].LogId}) + gotLogIds := []int{got[0].LogId, got[1].LogId} + require.ElementsMatch(t, []int{logs[0].Id, logs[1].Id}, gotLogIds) + require.NotContains(t, gotLogIds, logs[4].Id) for _, log := range got { require.Equal(t, 1, log.UserId) } diff --git a/review_go.diff b/review_go.diff deleted file mode 100644 index 852a44a..0000000 --- a/review_go.diff +++ /dev/null @@ -1,2011 +0,0 @@ -diff --git a/common/constants.go b/common/constants.go -index 3b000da..5e35bcb 100644 ---- a/common/constants.go -+++ b/common/constants.go -@@ -161,6 +161,7 @@ var QuotaRemindThreshold = 1000 - var PreConsumedQuota = 500 - - var RetryTimes = 0 -+var EmptyCompletionRetryEnabled = false - - //var RootUserEmail = "" - -diff --git a/constant/context_key.go b/constant/context_key.go -index 1afa7d4..ee07493 100644 ---- a/constant/context_key.go -+++ b/constant/context_key.go -@@ -55,6 +55,7 @@ const ( - ContextKeyLocalCountTokens ContextKey = "local_count_tokens" - - ContextKeySystemPromptOverride ContextKey = "system_prompt_override" -+ ContextKeyEmptyCompletionInfo ContextKey = "empty_completion_info" - - // ContextKeyFileSourcesToCleanup stores file sources that need cleanup when request ends - ContextKeyFileSourcesToCleanup ContextKey = "file_sources_to_cleanup" -diff --git a/controller/log.go b/controller/log.go -index 5d1f430..93f71e2 100644 ---- a/controller/log.go -+++ b/controller/log.go -@@ -35,6 +35,35 @@ func GetAllLogs(c *gin.Context) { - return - } - -+func GetLogDetail(c *gin.Context) { -+ id, err := strconv.Atoi(c.Param("id")) -+ if err != nil || id <= 0 { -+ common.ApiErrorMsg(c, "invalid log id") -+ return -+ } -+ log, err := model.GetLogById(id) -+ if err != nil { -+ common.ApiError(c, err) -+ return -+ } -+ common.ApiSuccess(c, log) -+} -+ -+func GetUserLogDetail(c *gin.Context) { -+ id, err := strconv.Atoi(c.Param("id")) -+ if err != nil || id <= 0 { -+ common.ApiErrorMsg(c, "invalid log id") -+ return -+ } -+ userId := c.GetInt("id") -+ log, err := model.GetUserLogById(userId, id) -+ if err != nil { -+ common.ApiError(c, err) -+ return -+ } -+ common.ApiSuccess(c, log) -+} -+ - func GetUserLogs(c *gin.Context) { - pageInfo := common.GetPageQuery(c) - userId := c.GetInt("id") -diff --git a/controller/relay.go b/controller/relay.go -index e005af8..96bb479 100644 ---- a/controller/relay.go -+++ b/controller/relay.go -@@ -341,6 +341,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.MaxAPIError, retryTimes int) b - if _, ok := c.Get("specific_channel_id"); ok { - return false - } -+ if openaiErr.GetErrorCode() == types.ErrorCodeEmptyResponse { -+ return true -+ } - code := openaiErr.StatusCode - if code >= 200 && code < 300 { - return false -diff --git a/controller/user.go b/controller/user.go -index 36e26fc..3ef2384 100644 ---- a/controller/user.go -+++ b/controller/user.go -@@ -298,8 +298,9 @@ func SearchUsers(c *gin.Context) { - status = &parsed - } - } -+ quotaStatus := c.Query("quota_status") - pageInfo := common.GetPageQuery(c) -- users, total, err := model.SearchUsers(keyword, group, role, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) -+ users, total, err := model.SearchUsers(keyword, group, role, status, quotaStatus, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) - if err != nil { - common.ApiError(c, err) - return -diff --git a/dto/openai_response.go b/dto/openai_response.go -index 09dab5f..b424a8d 100644 ---- a/dto/openai_response.go -+++ b/dto/openai_response.go -@@ -366,6 +366,7 @@ func ResponsesArgumentsString(arguments json.RawMessage) string { - type ResponsesOutputContent struct { - Type string `json:"type"` - Text string `json:"text"` -+ Refusal string `json:"refusal,omitempty"` - Annotations []interface{} `json:"annotations"` - } - -diff --git a/model/db_time.go b/model/db_time.go -index 2efedee..92db450 100644 ---- a/model/db_time.go -+++ b/model/db_time.go -@@ -1,19 +1,29 @@ - package model - --import "github.com/MAX-API-Next/MAX-API/common" -+import ( -+ "github.com/MAX-API-Next/MAX-API/common" -+ "gorm.io/gorm" -+) - - // GetDBTimestamp returns a UNIX timestamp from database time. - // Falls back to application time on error. - func GetDBTimestamp() int64 { -+ return getDBTimestampTx(DB) -+} -+ -+func getDBTimestampTx(tx *gorm.DB) int64 { -+ if tx == nil { -+ return common.GetTimestamp() -+ } - var ts int64 - var err error - switch { - case common.UsingPostgreSQL: -- err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error -+ err = tx.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error - case common.UsingSQLite: -- err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error -+ err = tx.Raw("SELECT strftime('%s','now')").Scan(&ts).Error - default: -- err = DB.Raw("SELECT UNIX_TIMESTAMP()").Scan(&ts).Error -+ err = tx.Raw("SELECT UNIX_TIMESTAMP()").Scan(&ts).Error - } - if err != nil || ts <= 0 { - return common.GetTimestamp() -diff --git a/model/log.go b/model/log.go -index 32b8a99..e17a5eb 100644 ---- a/model/log.go -+++ b/model/log.go -@@ -53,6 +53,7 @@ type Log struct { - RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"` - UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"` - Other string `json:"other"` -+ LogId int `json:"log_id,omitempty" gorm:"-"` - } - - // don't use iota, avoid change log type value -@@ -102,23 +103,75 @@ func userVisibleAuditInfo(adminInfoValue interface{}) map[string]interface{} { - return auditInfo - } - -+func stripAuditContentFields(auditInfo map[string]interface{}) { -+ if auditInfo == nil { -+ return -+ } -+ delete(auditInfo, "request_content") -+ delete(auditInfo, "response_content") -+} -+ -+func stripAuditContentValue(value interface{}) { -+ auditInfo, ok := value.(map[string]interface{}) -+ if !ok { -+ return -+ } -+ stripAuditContentFields(auditInfo) -+} -+ -+func stripLogAuditContent(log *Log) { -+ if log == nil || strings.TrimSpace(log.Other) == "" { -+ return -+ } -+ otherMap, err := common.StrToMap(log.Other) -+ if err != nil || otherMap == nil { -+ return -+ } -+ stripAuditContentValue(otherMap["admin_info"]) -+ stripAuditContentValue(otherMap["audit_info"]) -+ log.Other = common.MapToJsonStr(otherMap) -+} -+ -+func stripLogsAuditContent(logs []*Log) { -+ for _, log := range logs { -+ stripLogAuditContent(log) -+ } -+} -+ -+func formatUserLog(log *Log) { -+ if log == nil { -+ return -+ } -+ log.ChannelName = "" -+ log.Other = formatUserLogOther(log.Other, false) -+} -+ -+func formatUserLogOther(other string, stripAuditContent bool) string { -+ var otherMap map[string]interface{} -+ otherMap, _ = common.StrToMap(other) -+ if otherMap != nil { -+ auditInfo := userVisibleAuditInfo(otherMap["admin_info"]) -+ // Remove admin-only debug fields. -+ delete(otherMap, "admin_info") -+ delete(otherMap, "audit_info") -+ // delete(otherMap, "reject_reason") -+ delete(otherMap, "stream_status") -+ if auditInfo != nil { -+ if stripAuditContent { -+ stripAuditContentFields(auditInfo) -+ } -+ otherMap["audit_info"] = auditInfo -+ } -+ } -+ return common.MapToJsonStr(otherMap) -+} -+ - func formatUserLogs(logs []*Log, startIdx int) { - for i := range logs { -+ realId := logs[i].Id - logs[i].ChannelName = "" -- var otherMap map[string]interface{} -- otherMap, _ = common.StrToMap(logs[i].Other) -- if otherMap != nil { -- auditInfo := userVisibleAuditInfo(otherMap["admin_info"]) -- // Remove admin-only debug fields. -- delete(otherMap, "admin_info") -- delete(otherMap, "audit_info") -- // delete(otherMap, "reject_reason") -- delete(otherMap, "stream_status") -- if auditInfo != nil { -- otherMap["audit_info"] = auditInfo -- } -- } -- logs[i].Other = common.MapToJsonStr(otherMap) -+ logs[i].Other = formatUserLogOther(logs[i].Other, true) -+ logs[i].LogId = realId - logs[i].Id = startIdx + i + 1 - } - } -@@ -495,6 +548,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName - } - } - -+ stripLogsAuditContent(logs) - return logs, total, err - } - -@@ -544,6 +598,37 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int - return logs, total, err - } - -+func GetLogById(id int) (*Log, error) { -+ if id <= 0 { -+ return nil, errors.New("invalid log id") -+ } -+ log := &Log{} -+ err := LOG_DB.Where("id = ?", id).First(log).Error -+ if errors.Is(err, gorm.ErrRecordNotFound) { -+ return nil, errors.New("log not found") -+ } -+ if err != nil { -+ return nil, err -+ } -+ return log, nil -+} -+ -+func GetUserLogById(userId int, id int) (*Log, error) { -+ if id <= 0 { -+ return nil, errors.New("invalid log id") -+ } -+ log := &Log{} -+ err := LOG_DB.Where("id = ? AND user_id = ?", id, userId).First(log).Error -+ if errors.Is(err, gorm.ErrRecordNotFound) { -+ return nil, errors.New("log not found") -+ } -+ if err != nil { -+ return nil, err -+ } -+ formatUserLog(log) -+ return log, nil -+} -+ - type Stat struct { - Quota int `json:"quota"` - Rpm int `json:"rpm"` -diff --git a/model/log_test.go b/model/log_test.go -index 8ba8038..335b7ef 100644 ---- a/model/log_test.go -+++ b/model/log_test.go -@@ -19,11 +19,55 @@ func withLogAuditSettings(t *testing.T, requestEnabled bool, responseEnabled boo - }) - } - --func TestFormatUserLogsExposesOnlyUserAuditContent(t *testing.T) { -+func TestFormatUserLogDetailExposesOnlyUserAuditContent(t *testing.T) { -+ withLogAuditSettings(t, true, true) -+ -+ log := &Log{ -+ Id: 42, -+ ChannelName: "secret channel", -+ Other: common.MapToJsonStr(map[string]interface{}{ -+ "admin_info": map[string]interface{}{ -+ "request_content": "user prompt", -+ "request_content_truncated": true, -+ "response_content": "model answer", -+ "response_content_truncated": true, -+ "local_count_tokens": true, -+ "use_channel": []int{1, 2}, -+ }, -+ "audit_info": map[string]interface{}{ -+ "request_content": "stale audit content", -+ }, -+ "stream_status": map[string]interface{}{ -+ "status": "error", -+ }, -+ }), -+ } -+ -+ formatUserLog(log) -+ -+ other, err := common.StrToMap(log.Other) -+ require.NoError(t, err) -+ require.Equal(t, 42, log.Id) -+ require.Empty(t, log.ChannelName) -+ require.NotContains(t, other, "admin_info") -+ require.NotContains(t, other, "stream_status") -+ -+ auditInfo, ok := other["audit_info"].(map[string]interface{}) -+ require.True(t, ok) -+ require.Equal(t, "user prompt", auditInfo["request_content"]) -+ require.Equal(t, true, auditInfo["request_content_truncated"]) -+ require.Equal(t, "model answer", auditInfo["response_content"]) -+ require.Equal(t, true, auditInfo["response_content_truncated"]) -+ require.NotContains(t, auditInfo, "local_count_tokens") -+ require.NotContains(t, auditInfo, "use_channel") -+} -+ -+func TestFormatUserLogsStripsAuditContentFromList(t *testing.T) { - withLogAuditSettings(t, true, true) - - logs := []*Log{ - { -+ Id: 42, - Other: common.MapToJsonStr(map[string]interface{}{ - "admin_info": map[string]interface{}{ - "request_content": "user prompt", -@@ -33,12 +77,6 @@ func TestFormatUserLogsExposesOnlyUserAuditContent(t *testing.T) { - "local_count_tokens": true, - "use_channel": []int{1, 2}, - }, -- "audit_info": map[string]interface{}{ -- "request_content": "stale audit content", -- }, -- "stream_status": map[string]interface{}{ -- "status": "error", -- }, - }), - }, - } -@@ -47,18 +85,44 @@ func TestFormatUserLogsExposesOnlyUserAuditContent(t *testing.T) { - - other, err := common.StrToMap(logs[0].Other) - require.NoError(t, err) -- require.NotContains(t, other, "admin_info") -- require.NotContains(t, other, "stream_status") -+ require.Equal(t, 42, logs[0].LogId) - require.Equal(t, 11, logs[0].Id) -+ require.NotContains(t, other, "admin_info") - - auditInfo, ok := other["audit_info"].(map[string]interface{}) - require.True(t, ok) -- require.Equal(t, "user prompt", auditInfo["request_content"]) -+ require.NotContains(t, auditInfo, "request_content") -+ require.NotContains(t, auditInfo, "response_content") - require.Equal(t, true, auditInfo["request_content_truncated"]) -- require.Equal(t, "model answer", auditInfo["response_content"]) - require.Equal(t, true, auditInfo["response_content_truncated"]) -- require.NotContains(t, auditInfo, "local_count_tokens") -- require.NotContains(t, auditInfo, "use_channel") -+} -+ -+func TestStripLogAuditContentKeepsAdminMetadata(t *testing.T) { -+ log := &Log{ -+ Other: common.MapToJsonStr(map[string]interface{}{ -+ "admin_info": map[string]interface{}{ -+ "request_content": "user prompt", -+ "request_content_truncated": true, -+ "response_content": "model answer", -+ "response_content_truncated": true, -+ "local_count_tokens": true, -+ "use_channel": []int{1, 2}, -+ }, -+ }), -+ } -+ -+ stripLogAuditContent(log) -+ -+ other, err := common.StrToMap(log.Other) -+ require.NoError(t, err) -+ adminInfo, ok := other["admin_info"].(map[string]interface{}) -+ require.True(t, ok) -+ require.NotContains(t, adminInfo, "request_content") -+ require.NotContains(t, adminInfo, "response_content") -+ require.Equal(t, true, adminInfo["request_content_truncated"]) -+ require.Equal(t, true, adminInfo["response_content_truncated"]) -+ require.Equal(t, true, adminInfo["local_count_tokens"]) -+ require.Contains(t, adminInfo, "use_channel") - } - - func TestFormatUserLogsHidesAuditContentWhenDisabled(t *testing.T) { -@@ -66,6 +130,7 @@ func TestFormatUserLogsHidesAuditContentWhenDisabled(t *testing.T) { - - logs := []*Log{ - { -+ Id: 42, - Other: common.MapToJsonStr(map[string]interface{}{ - "admin_info": map[string]interface{}{ - "request_content": "user prompt", -@@ -79,6 +144,7 @@ func TestFormatUserLogsHidesAuditContentWhenDisabled(t *testing.T) { - - other, err := common.StrToMap(logs[0].Other) - require.NoError(t, err) -+ require.Equal(t, 42, logs[0].LogId) - require.NotContains(t, other, "admin_info") - require.NotContains(t, other, "audit_info") - } -diff --git a/model/option.go b/model/option.go -index fe48dc4..b81291e 100644 ---- a/model/option.go -+++ b/model/option.go -@@ -159,6 +159,7 @@ func InitOptionMap() { - //common.OptionMap["ChatLink2"] = common.ChatLink2 - common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64) - common.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes) -+ common.OptionMap["EmptyCompletionRetryEnabled"] = strconv.FormatBool(common.EmptyCompletionRetryEnabled) - common.OptionMap["DataExportInterval"] = strconv.Itoa(common.DataExportInterval) - common.OptionMap["DataExportDefaultTime"] = common.DataExportDefaultTime - common.OptionMap["DefaultCollapseSidebar"] = strconv.FormatBool(common.DefaultCollapseSidebar) -@@ -308,6 +309,8 @@ func updateOptionMap(key string, value string) (err error) { - common.EmailAliasRestrictionEnabled = boolValue - case "AutomaticDisableChannelEnabled": - common.AutomaticDisableChannelEnabled = boolValue -+ case "EmptyCompletionRetryEnabled": -+ common.EmptyCompletionRetryEnabled = boolValue - case "AutomaticEnableChannelEnabled": - common.AutomaticEnableChannelEnabled = boolValue - case "LogConsumeEnabled": -diff --git a/model/payment_method_guard_test.go b/model/payment_method_guard_test.go -index 2da03a9..23eadc4 100644 ---- a/model/payment_method_guard_test.go -+++ b/model/payment_method_guard_test.go -@@ -80,6 +80,13 @@ func countUserSubscriptionsForPaymentGuardTest(t *testing.T, userID int) int64 { - return count - } - -+func countTopUpsForPaymentGuardTest(t *testing.T, tradeNo string) int64 { -+ t.Helper() -+ var count int64 -+ require.NoError(t, DB.Model(&TopUp{}).Where("trade_no = ?", tradeNo).Count(&count).Error) -+ return count -+} -+ - func getUserQuotaForPaymentGuardTest(t *testing.T, userID int) int { - t.Helper() - var user User -@@ -172,3 +179,127 @@ func TestExpireSubscriptionOrder_RejectsMismatchedPaymentProvider(t *testing.T) - require.NotNil(t, order) - assert.Equal(t, common.TopUpStatusPending, order.Status) - } -+ -+func TestCompleteSubscriptionOrder_IdempotentDoesNotDuplicateSideEffects(t *testing.T) { -+ truncateTables(t) -+ -+ insertUserForPaymentGuardTest(t, 404, 0) -+ plan := insertSubscriptionPlanForPaymentGuardTest(t, 501) -+ insertSubscriptionOrderForPaymentGuardTest(t, "sub-idempotent-order", 404, plan.Id, PaymentProviderStripe) -+ -+ require.NoError(t, CompleteSubscriptionOrder("sub-idempotent-order", `{"event":"first"}`, PaymentProviderStripe, "card")) -+ require.NoError(t, CompleteSubscriptionOrder("sub-idempotent-order", `{"event":"second"}`, PaymentProviderStripe, "card")) -+ -+ order := GetSubscriptionOrderByTradeNo("sub-idempotent-order") -+ require.NotNil(t, order) -+ assert.Equal(t, common.TopUpStatusSuccess, order.Status) -+ assert.Equal(t, "card", order.PaymentMethod) -+ assert.Equal(t, `{"event":"first"}`, order.ProviderPayload) -+ assert.EqualValues(t, 1, countUserSubscriptionsForPaymentGuardTest(t, 404)) -+ assert.EqualValues(t, 1, countTopUpsForPaymentGuardTest(t, "sub-idempotent-order")) -+} -+ -+func TestPurchaseSubscriptionWithBalance_InsufficientQuotaDoesNotOverdraw(t *testing.T) { -+ truncateTables(t) -+ -+ plan := insertSubscriptionPlanForPaymentGuardTest(t, 601) -+ requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount) -+ require.NoError(t, err) -+ require.Greater(t, requiredQuota, 0) -+ insertUserForPaymentGuardTest(t, 505, requiredQuota-1) -+ -+ err = PurchaseSubscriptionWithBalance(505, plan.Id) -+ require.Error(t, err) -+ assert.Equal(t, requiredQuota-1, getUserQuotaForPaymentGuardTest(t, 505)) -+ assert.Zero(t, countUserSubscriptionsForPaymentGuardTest(t, 505)) -+} -+ -+func TestRedeem_UsedCodeDoesNotDoubleCredit(t *testing.T) { -+ truncateTables(t) -+ -+ insertUserForPaymentGuardTest(t, 606, 0) -+ redemption := &Redemption{ -+ Key: "redeem-guard-code", -+ Status: common.RedemptionCodeStatusEnabled, -+ Quota: 123, -+ CreatedTime: time.Now().Unix(), -+ } -+ require.NoError(t, DB.Create(redemption).Error) -+ -+ quota, err := Redeem("redeem-guard-code", 606) -+ require.NoError(t, err) -+ assert.Equal(t, 123, quota) -+ -+ quota, err = Redeem("redeem-guard-code", 606) -+ require.ErrorIs(t, err, ErrRedeemFailed) -+ assert.Zero(t, quota) -+ assert.Equal(t, 123, getUserQuotaForPaymentGuardTest(t, 606)) -+ -+ var reloaded Redemption -+ require.NoError(t, DB.Where("id = ?", redemption.Id).First(&reloaded).Error) -+ assert.Equal(t, common.RedemptionCodeStatusUsed, reloaded.Status) -+ assert.Equal(t, 606, reloaded.UsedUserId) -+} -+ -+func TestRefundSubscriptionPreConsume_IdempotentDoesNotDoubleRefund(t *testing.T) { -+ truncateTables(t) -+ -+ insertUserForPaymentGuardTest(t, 707, 0) -+ plan := insertSubscriptionPlanForPaymentGuardTest(t, 701) -+ sub := &UserSubscription{ -+ UserId: 707, -+ PlanId: plan.Id, -+ AmountTotal: 100, -+ AmountUsed: 50, -+ StartTime: time.Now().Add(-time.Hour).Unix(), -+ EndTime: time.Now().Add(time.Hour).Unix(), -+ Status: "active", -+ Source: "order", -+ } -+ require.NoError(t, DB.Create(sub).Error) -+ record := &SubscriptionPreConsumeRecord{ -+ RequestId: "refund-guard-request", -+ UserId: 707, -+ UserSubscriptionId: sub.Id, -+ PreConsumed: 30, -+ Status: "consumed", -+ } -+ require.NoError(t, DB.Create(record).Error) -+ -+ require.NoError(t, RefundSubscriptionPreConsume("refund-guard-request")) -+ require.NoError(t, RefundSubscriptionPreConsume("refund-guard-request")) -+ -+ var reloadedSub UserSubscription -+ require.NoError(t, DB.Where("id = ?", sub.Id).First(&reloadedSub).Error) -+ assert.EqualValues(t, 20, reloadedSub.AmountUsed) -+ -+ var reloadedRecord SubscriptionPreConsumeRecord -+ require.NoError(t, DB.Where("id = ?", record.Id).First(&reloadedRecord).Error) -+ assert.Equal(t, "refunded", reloadedRecord.Status) -+} -+ -+func TestPostConsumeUserSubscriptionDelta_ReturnsAppliedNegativeDelta(t *testing.T) { -+ truncateTables(t) -+ -+ insertUserForPaymentGuardTest(t, 808, 0) -+ plan := insertSubscriptionPlanForPaymentGuardTest(t, 801) -+ sub := &UserSubscription{ -+ UserId: 808, -+ PlanId: plan.Id, -+ AmountTotal: 100, -+ AmountUsed: 5, -+ StartTime: time.Now().Add(-time.Hour).Unix(), -+ EndTime: time.Now().Add(time.Hour).Unix(), -+ Status: "active", -+ Source: "order", -+ } -+ require.NoError(t, DB.Create(sub).Error) -+ -+ applied, err := PostConsumeUserSubscriptionDelta(sub.Id, -10) -+ require.NoError(t, err) -+ assert.EqualValues(t, -5, applied) -+ -+ var reloadedSub UserSubscription -+ require.NoError(t, DB.Where("id = ?", sub.Id).First(&reloadedSub).Error) -+ assert.EqualValues(t, 0, reloadedSub.AmountUsed) -+} -diff --git a/model/redemption.go b/model/redemption.go -index 5d3a0bc..21b22a6 100644 ---- a/model/redemption.go -+++ b/model/redemption.go -@@ -127,25 +127,43 @@ func Redeem(key string, userId int) (quota int, err error) { - } - common.RandomSleep() - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error -+ err := withRowLock(tx).Where(keyCol+" = ?", key).First(redemption).Error - if err != nil { - return errors.New("无效的兑换码") - } - if redemption.Status != common.RedemptionCodeStatusEnabled { - return errors.New("该兑换码已被使用") - } -- if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { -+ now := common.GetTimestamp() -+ if redemption.ExpiredTime != 0 && redemption.ExpiredTime < now { - return errors.New("该兑换码已过期") - } -- err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error -- if err != nil { -- return err -+ result := tx.Model(&Redemption{}). -+ Where("id = ? AND status = ? AND (expired_time = 0 OR expired_time >= ?)", redemption.Id, common.RedemptionCodeStatusEnabled, now). -+ Updates(map[string]interface{}{ -+ "redeemed_time": now, -+ "status": common.RedemptionCodeStatusUsed, -+ "used_user_id": userId, -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("该兑换码已被使用") - } -- redemption.RedeemedTime = common.GetTimestamp() -+ -+ userResult := tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)) -+ if userResult.Error != nil { -+ return userResult.Error -+ } -+ if userResult.RowsAffected != 1 { -+ return errors.New("无效的 user id") -+ } -+ redemption.RedeemedTime = now - redemption.Status = common.RedemptionCodeStatusUsed - redemption.UsedUserId = userId -- err = tx.Save(redemption).Error -- return err -+ quota = redemption.Quota -+ return nil - }) - if err != nil { - common.SysError("redemption failed: " + err.Error()) -diff --git a/model/subscription.go b/model/subscription.go -index 3a9d430..99cefe8 100644 ---- a/model/subscription.go -+++ b/model/subscription.go -@@ -38,6 +38,67 @@ var ( - ErrSubscriptionOrderStatusInvalid = errors.New("subscription order status invalid") - ) - -+func completePendingSubscriptionOrderTx(tx *gorm.DB, order *SubscriptionOrder, providerPayload string, actualPaymentMethod string) (bool, error) { -+ if tx == nil || order == nil || order.Id == 0 { -+ return false, ErrSubscriptionOrderNotFound -+ } -+ now := common.GetTimestamp() -+ updates := map[string]interface{}{ -+ "status": common.TopUpStatusSuccess, -+ "complete_time": now, -+ } -+ if providerPayload != "" { -+ updates["provider_payload"] = providerPayload -+ order.ProviderPayload = providerPayload -+ } -+ if actualPaymentMethod != "" && order.PaymentMethod != actualPaymentMethod { -+ updates["payment_method"] = actualPaymentMethod -+ order.PaymentMethod = actualPaymentMethod -+ } -+ result := tx.Model(&SubscriptionOrder{}). -+ Where("id = ? AND status = ?", order.Id, common.TopUpStatusPending). -+ Updates(updates) -+ if result.Error != nil { -+ return false, result.Error -+ } -+ if result.RowsAffected != 1 { -+ var latest SubscriptionOrder -+ if err := tx.Select("status").Where("id = ?", order.Id).First(&latest).Error; err == nil && latest.Status == common.TopUpStatusSuccess { -+ return false, nil -+ } -+ return false, ErrSubscriptionOrderStatusInvalid -+ } -+ order.Status = common.TopUpStatusSuccess -+ order.CompleteTime = now -+ return true, nil -+} -+ -+func expirePendingSubscriptionOrderTx(tx *gorm.DB, order *SubscriptionOrder) error { -+ if tx == nil || order == nil || order.Id == 0 { -+ return ErrSubscriptionOrderNotFound -+ } -+ now := common.GetTimestamp() -+ result := tx.Model(&SubscriptionOrder{}). -+ Where("id = ? AND status = ?", order.Id, common.TopUpStatusPending). -+ Updates(map[string]interface{}{ -+ "status": common.TopUpStatusExpired, -+ "complete_time": now, -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ var latest SubscriptionOrder -+ if err := tx.Select("status").Where("id = ?", order.Id).First(&latest).Error; err == nil && latest.Status != common.TopUpStatusPending { -+ return nil -+ } -+ return ErrSubscriptionOrderStatusInvalid -+ } -+ order.Status = common.TopUpStatusExpired -+ order.CompleteTime = now -+ return nil -+} -+ - const ( - subscriptionPlanCacheNamespace = "max-api:subscription_plan:v1" - subscriptionPlanInfoCacheNamespace = "max-api:subscription_plan_info:v1" -@@ -468,7 +529,7 @@ func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *Subscriptio - return nil, errors.New("已达到该套餐购买上限") - } - } -- nowUnix := GetDBTimestamp() -+ nowUnix := getDBTimestampTx(tx) - now := time.Unix(nowUnix, 0) - endUnix, err := calcPlanEndTime(now, plan) - if err != nil { -@@ -535,7 +596,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP - var upgradeGroup string - err := DB.Transaction(func(tx *gorm.DB) error { - var order SubscriptionOrder -- if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { -+ if err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { - return ErrSubscriptionOrderNotFound - } - if expectedPaymentProvider != "" && order.PaymentProvider != expectedPaymentProvider { -@@ -547,7 +608,7 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP - if order.Status != common.TopUpStatusPending { - return ErrSubscriptionOrderStatusInvalid - } -- plan, err := GetSubscriptionPlanById(order.PlanId) -+ plan, err := getSubscriptionPlanByIdTx(tx, order.PlanId) - if err != nil { - return err - } -@@ -555,22 +616,18 @@ func CompleteSubscriptionOrder(tradeNo string, providerPayload string, expectedP - // still allow completion for already purchased orders - } - upgradeGroup = strings.TrimSpace(plan.UpgradeGroup) -- _, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order") -+ claimed, err := completePendingSubscriptionOrderTx(tx, &order, providerPayload, actualPaymentMethod) - if err != nil { - return err - } -- if err := upsertSubscriptionTopUpTx(tx, &order); err != nil { -- return err -- } -- order.Status = common.TopUpStatusSuccess -- order.CompleteTime = common.GetTimestamp() -- if providerPayload != "" { -- order.ProviderPayload = providerPayload -+ if !claimed { -+ return nil - } -- if actualPaymentMethod != "" && order.PaymentMethod != actualPaymentMethod { -- order.PaymentMethod = actualPaymentMethod -+ _, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order") -+ if err != nil { -+ return err - } -- if err := tx.Save(&order).Error; err != nil { -+ if err := upsertSubscriptionTopUpTx(tx, &order); err != nil { - return err - } - logUserId = order.UserId -@@ -638,7 +695,7 @@ func ExpireSubscriptionOrder(tradeNo string, expectedPaymentProvider string) err - } - return DB.Transaction(func(tx *gorm.DB) error { - var order SubscriptionOrder -- if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { -+ if err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil { - return ErrSubscriptionOrderNotFound - } - if expectedPaymentProvider != "" && order.PaymentProvider != expectedPaymentProvider { -@@ -647,9 +704,7 @@ func ExpireSubscriptionOrder(tradeNo string, expectedPaymentProvider string) err - if order.Status != common.TopUpStatusPending { - return nil - } -- order.Status = common.TopUpStatusExpired -- order.CompleteTime = common.GetTimestamp() -- return tx.Save(&order).Error -+ return expirePendingSubscriptionOrderTx(tx, &order) - }) - } - -@@ -721,16 +776,20 @@ func PurchaseSubscriptionWithBalance(userId int, planId int) error { - } - - var user User -- if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil { -+ if err := withRowLock(tx).Where("id = ?", userId).First(&user).Error; err != nil { - return err - } - if requiredQuota > 0 && user.Quota < requiredQuota { - return errors.New("余额不足") - } - if requiredQuota > 0 { -- if err := tx.Model(&User{}).Where("id = ?", userId). -- Update("quota", gorm.Expr("quota - ?", requiredQuota)).Error; err != nil { -- return err -+ result := tx.Model(&User{}).Where("id = ? AND quota >= ?", userId, requiredQuota). -+ Update("quota", gorm.Expr("quota - ?", requiredQuota)) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("余额不足") - } - } - -@@ -851,7 +910,7 @@ func AdminInvalidateUserSubscription(userSubscriptionId int) (string, error) { - var userId int - err := DB.Transaction(func(tx *gorm.DB) error { - var sub UserSubscription -- if err := tx.Set("gorm:query_option", "FOR UPDATE"). -+ if err := withRowLock(tx). - Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil { - return err - } -@@ -896,7 +955,7 @@ func AdminDeleteUserSubscription(userSubscriptionId int) (string, error) { - var userId int - err := DB.Transaction(func(tx *gorm.DB) error { - var sub UserSubscription -- if err := tx.Set("gorm:query_option", "FOR UPDATE"). -+ if err := withRowLock(tx). - Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil { - return err - } -@@ -1119,7 +1178,7 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string, - } - - var subs []UserSubscription -- if err := tx.Set("gorm:query_option", "FOR UPDATE"). -+ if err := withRowLock(tx). - Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now). - Order("end_time asc, id asc"). - Find(&subs).Error; err != nil { -@@ -1157,24 +1216,44 @@ func PreConsumeUserSubscription(requestId string, userId int, modelName string, - if dup.Status == "refunded" { - return errors.New("subscription pre-consume already refunded") - } -- returnValue.UserSubscriptionId = sub.Id -+ var dupSub UserSubscription -+ if err3 := tx.Where("id = ?", dup.UserSubscriptionId).First(&dupSub).Error; err3 != nil { -+ return err3 -+ } -+ returnValue.UserSubscriptionId = dupSub.Id - returnValue.PreConsumed = dup.PreConsumed -- returnValue.AmountTotal = sub.AmountTotal -- returnValue.AmountUsedBefore = sub.AmountUsed -- returnValue.AmountUsedAfter = sub.AmountUsed -+ returnValue.AmountTotal = dupSub.AmountTotal -+ returnValue.AmountUsedBefore = dupSub.AmountUsed -+ returnValue.AmountUsedAfter = dupSub.AmountUsed - return nil - } - return err - } -- sub.AmountUsed += amount -- if err := tx.Save(&sub).Error; err != nil { -+ updateQuery := tx.Model(&UserSubscription{}). -+ Where("id = ? AND status = ? AND end_time > ?", sub.Id, "active", now). -+ Where("amount_total <= 0 OR amount_used + ? <= amount_total", amount) -+ result := updateQuery.Updates(map[string]interface{}{ -+ "amount_used": gorm.Expr("amount_used + ?", amount), -+ "updated_at": common.GetTimestamp(), -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ if err := tx.Delete(record).Error; err != nil { -+ return err -+ } -+ continue -+ } -+ var updatedSub UserSubscription -+ if err := tx.Where("id = ?", sub.Id).First(&updatedSub).Error; err != nil { - return err - } - returnValue.UserSubscriptionId = sub.Id - returnValue.PreConsumed = amount -- returnValue.AmountTotal = sub.AmountTotal -+ returnValue.AmountTotal = updatedSub.AmountTotal - returnValue.AmountUsedBefore = usedBefore -- returnValue.AmountUsedAfter = sub.AmountUsed -+ returnValue.AmountUsedAfter = updatedSub.AmountUsed - return nil - } - return fmt.Errorf("subscription quota insufficient, need=%d", amount) -@@ -1192,22 +1271,32 @@ func RefundSubscriptionPreConsume(requestId string) error { - } - return DB.Transaction(func(tx *gorm.DB) error { - var record SubscriptionPreConsumeRecord -- if err := tx.Set("gorm:query_option", "FOR UPDATE"). -+ if err := withRowLock(tx). - Where("request_id = ?", requestId).First(&record).Error; err != nil { - return err - } - if record.Status == "refunded" { - return nil - } -+ result := tx.Model(&SubscriptionPreConsumeRecord{}). -+ Where("id = ? AND status <> ?", record.Id, "refunded"). -+ Updates(map[string]interface{}{ -+ "status": "refunded", -+ "updated_at": common.GetTimestamp(), -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return nil -+ } - if record.PreConsumed <= 0 { -- record.Status = "refunded" -- return tx.Save(&record).Error -+ return nil - } -- if err := PostConsumeUserSubscriptionDelta(record.UserSubscriptionId, -record.PreConsumed); err != nil { -+ if _, err := postConsumeUserSubscriptionDeltaTx(tx, record.UserSubscriptionId, -record.PreConsumed); err != nil { - return err - } -- record.Status = "refunded" -- return tx.Save(&record).Error -+ return nil - }) - } - -@@ -1236,7 +1325,7 @@ func ResetDueSubscriptions(limit int) (int, error) { - } - err = DB.Transaction(func(tx *gorm.DB) error { - var locked UserSubscription -- if err := tx.Set("gorm:query_option", "FOR UPDATE"). -+ if err := withRowLock(tx). - Where("id = ? AND next_reset_time > 0 AND next_reset_time <= ?", subCopy.Id, now). - First(&locked).Error; err != nil { - return nil -@@ -1294,28 +1383,73 @@ func GetSubscriptionPlanInfoByUserSubscriptionId(userSubscriptionId int) (*Subsc - } - - // Update subscription used amount by delta (positive consume more, negative refund). --func PostConsumeUserSubscriptionDelta(userSubscriptionId int, delta int64) error { -+// It returns the actual applied delta because negative deltas are floored at zero. -+func PostConsumeUserSubscriptionDelta(userSubscriptionId int, delta int64) (int64, error) { - if userSubscriptionId <= 0 { -- return errors.New("invalid userSubscriptionId") -+ return 0, errors.New("invalid userSubscriptionId") - } - if delta == 0 { -- return nil -+ return 0, nil - } -- return DB.Transaction(func(tx *gorm.DB) error { -- var sub UserSubscription -- if err := tx.Set("gorm:query_option", "FOR UPDATE"). -- Where("id = ?", userSubscriptionId). -- First(&sub).Error; err != nil { -- return err -- } -+ var applied int64 -+ err := DB.Transaction(func(tx *gorm.DB) error { -+ var err error -+ applied, err = postConsumeUserSubscriptionDeltaTx(tx, userSubscriptionId, delta) -+ return err -+ }) -+ return applied, err -+} -+ -+func postConsumeUserSubscriptionDeltaTx(tx *gorm.DB, userSubscriptionId int, delta int64) (int64, error) { -+ if tx == nil { -+ return 0, errors.New("invalid transaction") -+ } -+ var sub UserSubscription -+ if err := withRowLock(tx). -+ Where("id = ?", userSubscriptionId). -+ First(&sub).Error; err != nil { -+ return 0, err -+ } -+ if delta > 0 { - newUsed := sub.AmountUsed + delta -- if newUsed < 0 { -- newUsed = 0 -- } - if sub.AmountTotal > 0 && newUsed > sub.AmountTotal { -- return fmt.Errorf("subscription used exceeds total, used=%d total=%d", newUsed, sub.AmountTotal) -+ return 0, fmt.Errorf("subscription used exceeds total, used=%d total=%d", newUsed, sub.AmountTotal) - } -- sub.AmountUsed = newUsed -- return tx.Save(&sub).Error -- }) -+ updateQuery := tx.Model(&UserSubscription{}).Where("id = ?", userSubscriptionId) -+ if sub.AmountTotal > 0 { -+ updateQuery = updateQuery.Where("amount_used + ? <= amount_total", delta) -+ } -+ result := updateQuery.Updates(map[string]interface{}{ -+ "amount_used": gorm.Expr("amount_used + ?", delta), -+ "updated_at": common.GetTimestamp(), -+ }) -+ if result.Error != nil { -+ return 0, result.Error -+ } -+ if result.RowsAffected != 1 { -+ return 0, fmt.Errorf("subscription used exceeds total, used=%d total=%d", newUsed, sub.AmountTotal) -+ } -+ return delta, nil -+ } -+ -+ if sub.AmountUsed <= 0 { -+ return 0, nil -+ } -+ applied := delta -+ if -delta > sub.AmountUsed { -+ applied = -sub.AmountUsed -+ } -+ result := tx.Model(&UserSubscription{}). -+ Where("id = ?", userSubscriptionId). -+ Updates(map[string]interface{}{ -+ "amount_used": gorm.Expr("CASE WHEN amount_used + ? < 0 THEN 0 ELSE amount_used + ? END", delta, delta), -+ "updated_at": common.GetTimestamp(), -+ }) -+ if result.Error != nil { -+ return 0, result.Error -+ } -+ if result.RowsAffected != 1 { -+ return 0, errors.New("subscription update conflict") -+ } -+ return applied, nil - } -diff --git a/model/task_cas_test.go b/model/task_cas_test.go -index 86289c0..3aff847 100644 ---- a/model/task_cas_test.go -+++ b/model/task_cas_test.go -@@ -41,10 +41,12 @@ func TestMain(m *testing.M) { - &Log{}, - &Channel{}, - &Ability{}, -+ &Redemption{}, - &TopUp{}, - &SubscriptionPlan{}, - &SubscriptionOrder{}, - &UserSubscription{}, -+ &SubscriptionPreConsumeRecord{}, - &PerfMetric{}, - &SystemTask{}, - &SystemInstance{}, -@@ -64,10 +66,12 @@ func truncateTables(t *testing.T) { - DB.Exec("DELETE FROM logs") - DB.Exec("DELETE FROM channels") - DB.Exec("DELETE FROM abilities") -+ DB.Exec("DELETE FROM redemptions") - DB.Exec("DELETE FROM top_ups") - DB.Exec("DELETE FROM subscription_orders") - DB.Exec("DELETE FROM subscription_plans") - DB.Exec("DELETE FROM user_subscriptions") -+ DB.Exec("DELETE FROM subscription_pre_consume_records") - DB.Exec("DELETE FROM perf_metrics") - DB.Exec("DELETE FROM system_tasks") - DB.Exec("DELETE FROM system_instances") -diff --git a/model/topup.go b/model/topup.go -index 274b707..37b22db 100644 ---- a/model/topup.go -+++ b/model/topup.go -@@ -47,6 +47,45 @@ var ( - ErrTopUpStatusInvalid = errors.New("topup status invalid") - ) - -+func updatePendingTopUpStatusTx(tx *gorm.DB, topUp *TopUp, targetStatus string) error { -+ if tx == nil || topUp == nil || topUp.Id == 0 { -+ return ErrTopUpNotFound -+ } -+ result := tx.Model(&TopUp{}). -+ Where("id = ? AND status = ?", topUp.Id, common.TopUpStatusPending). -+ Update("status", targetStatus) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return ErrTopUpStatusInvalid -+ } -+ topUp.Status = targetStatus -+ return nil -+} -+ -+func markPendingTopUpSuccessTx(tx *gorm.DB, topUp *TopUp) error { -+ if tx == nil || topUp == nil || topUp.Id == 0 { -+ return ErrTopUpNotFound -+ } -+ now := common.GetTimestamp() -+ result := tx.Model(&TopUp{}). -+ Where("id = ? AND status = ?", topUp.Id, common.TopUpStatusPending). -+ Updates(map[string]interface{}{ -+ "status": common.TopUpStatusSuccess, -+ "complete_time": now, -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return ErrTopUpStatusInvalid -+ } -+ topUp.Status = common.TopUpStatusSuccess -+ topUp.CompleteTime = now -+ return nil -+} -+ - func (topUp *TopUp) Insert() error { - var err error - err = DB.Create(topUp).Error -@@ -91,7 +130,7 @@ func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, ta - - 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 { -+ if err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { - return ErrTopUpNotFound - } - if expectedPaymentProvider != "" && topUp.PaymentProvider != expectedPaymentProvider { -@@ -101,8 +140,7 @@ func UpdatePendingTopUpStatus(tradeNo string, expectedPaymentProvider string, ta - return ErrTopUpStatusInvalid - } - -- topUp.Status = targetStatus -- return tx.Save(topUp).Error -+ return updatePendingTopUpStatusTx(tx, topUp, targetStatus) - }) - } - -@@ -120,7 +158,7 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", referenceId).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -133,17 +171,17 @@ func Recharge(referenceId string, customerId string, callerIp string) (err error - return errors.New("充值订单状态错误") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- err = tx.Save(topUp).Error -- if err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - - quota = topUp.Money * common.QuotaPerUnit -- err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error -- if err != nil { -- return err -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("充值用户不存在") - } - - return nil -@@ -335,7 +373,7 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { - err := 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 { -+ if err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil { - return errors.New("充值订单不存在") - } - -@@ -363,16 +401,17 @@ func ManualCompleteTopUp(tradeNo string, callerIp string) error { - return errors.New("无效的充值额度") - } - -- // 标记完成 -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- if err := tx.Save(topUp).Error; err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - - // 增加用户额度(立即写库,保持一致性) -- if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { -- return err -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("充值用户不存在") - } - - userId = topUp.UserId -@@ -403,7 +442,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", referenceId).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -416,10 +455,7 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string - return errors.New("充值订单状态错误") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- err = tx.Save(topUp).Error -- if err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - -@@ -446,9 +482,12 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string - } - } - -- err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields).Error -- if err != nil { -- return err -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(updateFields) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("充值用户不存在") - } - - return nil -@@ -478,7 +517,7 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -502,14 +541,16 @@ func RechargeWaffo(tradeNo string, callerIp string) (err error) { - return errors.New("无效的充值额度") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- if err := tx.Save(topUp).Error; err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - -- if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { -- return err -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("充值用户不存在") - } - - return nil -@@ -541,7 +582,7 @@ func RechargeWaffoPancake(tradeNo string) (err error) { - } - - err = DB.Transaction(func(tx *gorm.DB) error { -- err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error -+ err := withRowLock(tx).Where(refCol+" = ?", tradeNo).First(topUp).Error - if err != nil { - return errors.New("充值订单不存在") - } -@@ -563,14 +604,16 @@ func RechargeWaffoPancake(tradeNo string) (err error) { - return errors.New("无效的充值额度") - } - -- topUp.CompleteTime = common.GetTimestamp() -- topUp.Status = common.TopUpStatusSuccess -- if err := tx.Save(topUp).Error; err != nil { -+ if err := markPendingTopUpSuccessTx(tx, topUp); err != nil { - return err - } - -- if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { -- return err -+ result := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("充值用户不存在") - } - - return nil -diff --git a/model/user.go b/model/user.go -index b82ed09..2d8e4f5 100644 ---- a/model/user.go -+++ b/model/user.go -@@ -226,38 +226,31 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err - return users, total, nil - } - --func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) { -- var users []*User -- var total int64 -- var err error -- -- // 开始事务 -- tx := DB.Begin() -- if tx.Error != nil { -- return nil, 0, tx.Error -- } -- defer func() { -- if r := recover(); r != nil { -- tx.Rollback() -- } -- }() -+const ( -+ UserQuotaStatusNegative = "negative" -+ UserQuotaStatusZero = "zero" -+ UserQuotaStatusPositive = "positive" -+) - -- // 构建基础查询 -+func buildSearchUsersQuery(tx *gorm.DB, keyword string, group string, role *int, status *int, quotaStatus string) *gorm.DB { - query := tx.Unscoped().Model(&User{}) - -- // 构建搜索条件 -- likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" -- likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"} -+ keyword = strings.TrimSpace(keyword) -+ if keyword != "" { -+ // 构建搜索条件 -+ likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" -+ likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"} - -- // 尝试将关键字转换为整数ID -- keywordInt, err := strconv.Atoi(keyword) -- if err == nil { -- // 如果是数字,同时搜索ID和其他字段 -- likeCondition = "id = ? OR " + likeCondition -- likeArgs = append([]interface{}{keywordInt}, likeArgs...) -- } -+ // 尝试将关键字转换为整数ID -+ keywordInt, err := strconv.Atoi(keyword) -+ if err == nil { -+ // 如果是数字,同时搜索ID和其他字段 -+ likeCondition = "id = ? OR " + likeCondition -+ likeArgs = append([]interface{}{keywordInt}, likeArgs...) -+ } - -- query = query.Where("("+likeCondition+")", likeArgs...) -+ query = query.Where("("+likeCondition+")", likeArgs...) -+ } - if group != "" { - query = query.Where(commonGroupCol+" = ?", group) - } -@@ -271,6 +264,36 @@ func SearchUsers(keyword string, group string, role *int, status *int, startIdx - query = query.Where("deleted_at IS NULL").Where("status = ?", *status) - } - } -+ switch strings.ToLower(strings.TrimSpace(quotaStatus)) { -+ case UserQuotaStatusNegative: -+ query = query.Where("quota < 0") -+ case UserQuotaStatusZero: -+ query = query.Where("quota = 0") -+ case UserQuotaStatusPositive: -+ query = query.Where("quota > 0") -+ } -+ -+ return query -+} -+ -+func SearchUsers(keyword string, group string, role *int, status *int, quotaStatus string, startIdx int, num int) ([]*User, int64, error) { -+ var users []*User -+ var total int64 -+ var err error -+ -+ // 开始事务 -+ tx := DB.Begin() -+ if tx.Error != nil { -+ return nil, 0, tx.Error -+ } -+ defer func() { -+ if r := recover(); r != nil { -+ tx.Rollback() -+ } -+ }() -+ -+ // 构建基础查询 -+ query := buildSearchUsersQuery(tx, keyword, group, role, status, quotaStatus) - - // 获取总数 - err = query.Count(&total).Error -@@ -360,7 +383,7 @@ func (user *User) TransferAffQuotaToQuota(quota int) error { - defer tx.Rollback() // 确保在函数退出时事务能回滚 - - // 加锁查询用户以确保数据一致性 -- err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, user.Id).Error -+ err := withRowLock(tx).First(user, user.Id).Error - if err != nil { - return err - } -@@ -371,14 +394,20 @@ func (user *User) TransferAffQuotaToQuota(quota int) error { - } - - // 更新用户额度 -+ result := tx.Model(&User{}).Where("id = ? AND aff_quota >= ?", user.Id, quota). -+ Updates(map[string]interface{}{ -+ "aff_quota": gorm.Expr("aff_quota - ?", quota), -+ "quota": gorm.Expr("quota + ?", quota), -+ }) -+ if result.Error != nil { -+ return result.Error -+ } -+ if result.RowsAffected != 1 { -+ return errors.New("邀请额度不足!") -+ } - user.AffQuota -= quota - user.Quota += quota - -- // 保存用户状态 -- if err := tx.Save(user).Error; err != nil { -- return err -- } -- - // 提交事务 - return tx.Commit().Error - } -diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go -index 7d67451..4d425f8 100644 ---- a/relay/channel/openai/chat_via_responses.go -+++ b/relay/channel/openai/chat_via_responses.go -@@ -64,6 +64,14 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - if err != nil { - return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) - } -+ emptyCompletion := isEmptyResponsesCompletion(&responsesResp) -+ if emptyCompletion { -+ willRetry := shouldRetryEmptyCompletion(c, info) -+ recordEmptyCompletion(c, info, usage, "empty_responses_output", responsesOutputTypes(&responsesResp), willRetry) -+ if willRetry { -+ return nil, newEmptyCompletionRetryError() -+ } -+ } - if usage == nil || usage.TotalTokens == 0 { - outputAuditText := service.ExtractOutputTextFromResponses(&responsesResp) - usage = service.ResponseText2Usage(c, outputAuditText, info.UpstreamModelName, info.GetEstimatePromptTokens()) -@@ -74,6 +82,9 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - } else if service.ResponseAuditEnabled() { - service.SetRelayResponseAuditContent(info, service.ExtractOutputTextFromResponses(&responsesResp)) - } -+ if !emptyCompletion { -+ recordEmptyCompletionRetrySuccess(c, info, usage) -+ } - - var responseBody []byte - switch info.RelayFormat { -@@ -106,13 +117,14 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - model := info.UpstreamModelName - - var ( -- usage = &dto.Usage{} -- outputText strings.Builder -- usageText strings.Builder -- sentStart bool -- sentStop bool -- sawToolCall bool -- streamErr *types.MaxAPIError -+ usage = &dto.Usage{} -+ outputText strings.Builder -+ usageText strings.Builder -+ sentStart bool -+ sentStop bool -+ sawToolCall bool -+ hasVisiblePayload bool -+ streamErr *types.MaxAPIError - ) - - toolCallIndexByID := make(map[string]int) -@@ -231,6 +243,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - return false - } - hasSentReasoningSummary = true -+ hasVisiblePayload = true - return true - } - -@@ -289,6 +302,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - return false - } - sawToolCall = true -+ hasVisiblePayload = true - - // Include tool call data in the local builder for fallback token estimation. - if tool.Function.Name != "" { -@@ -362,14 +376,14 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - // } - - case "response.output_text.delta": -- if !sendStartIfNeeded() { -- sr.Stop(streamErr) -- return -- } -- - if streamResp.Delta != "" { -+ if !sendStartIfNeeded() { -+ sr.Stop(streamErr) -+ return -+ } - outputText.WriteString(streamResp.Delta) - usageText.WriteString(streamResp.Delta) -+ hasVisiblePayload = true - delta := streamResp.Delta - chunk := &dto.ChatCompletionsStreamResponse{ - Id: responseId, -@@ -447,7 +461,9 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - case "response.function_call_arguments.done": - - case "response.completed": -+ completedOutputTypes := []string(nil) - if streamResp.Response != nil { -+ completedOutputTypes = responsesOutputTypes(streamResp.Response) - if streamResp.Response.Model != "" { - model = streamResp.Response.Model - } -@@ -477,6 +493,66 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - usage.CompletionTokenDetails.ReasoningTokens = streamResp.Response.Usage.CompletionTokenDetails.ReasoningTokens - } - } -+ if outputText.Len() == 0 { -+ completedText := service.ExtractOutputTextFromResponses(streamResp.Response) -+ if strings.TrimSpace(completedText) != "" { -+ if !sendStartIfNeeded() { -+ sr.Stop(streamErr) -+ return -+ } -+ outputText.WriteString(completedText) -+ usageText.WriteString(completedText) -+ delta := completedText -+ chunk := &dto.ChatCompletionsStreamResponse{ -+ Id: responseId, -+ Object: "chat.completion.chunk", -+ Created: createAt, -+ Model: model, -+ Choices: []dto.ChatCompletionsStreamResponseChoice{ -+ { -+ Index: 0, -+ Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ -+ Content: &delta, -+ }, -+ }, -+ }, -+ } -+ if !sendChatChunk(chunk) { -+ sr.Stop(streamErr) -+ return -+ } -+ hasVisiblePayload = true -+ } -+ } -+ if !sawToolCall { -+ for _, out := range streamResp.Response.Output { -+ if out.Type != "function_call" { -+ continue -+ } -+ itemID := strings.TrimSpace(out.ID) -+ callID := strings.TrimSpace(out.CallId) -+ if callID == "" { -+ callID = itemID -+ } -+ if !sendToolCallDelta(callID, strings.TrimSpace(out.Name), out.ArgumentsString()) { -+ sr.Stop(streamErr) -+ return -+ } -+ } -+ } -+ if streamResp.Response.HasImageGenerationCall() { -+ hasVisiblePayload = true -+ } -+ } -+ -+ if !hasVisiblePayload { -+ willRetry := shouldRetryEmptyCompletion(c, info) -+ recordEmptyCompletion(c, info, usage, "empty_responses_stream_output", completedOutputTypes, willRetry) -+ if willRetry { -+ streamErr = newEmptyCompletionRetryError() -+ sr.Stop(streamErr) -+ return -+ } - } - - if !sendStartIfNeeded() { -@@ -557,5 +633,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo - if service.ResponseAuditEnabled() { - service.SetRelayResponseAuditContent(info, auditText) - } -+ if hasVisiblePayload { -+ recordEmptyCompletionRetrySuccess(c, info, usage) -+ } - return usage, nil - } -diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go -index 0be133d..100d3ee 100644 ---- a/relay/channel/openai/relay_responses.go -+++ b/relay/channel/openai/relay_responses.go -@@ -43,9 +43,6 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http - c.Set("image_generation_call_size", responsesResponse.GetSize()) - } - -- // 写入新的 response body -- service.IOCopyBytesGracefully(c, resp, responseBody) -- - // compute usage - usage := dto.Usage{} - if responsesResponse.Usage != nil { -@@ -56,6 +53,20 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http - usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens - } - } -+ emptyCompletion := isEmptyResponsesCompletion(&responsesResponse) -+ if emptyCompletion { -+ willRetry := shouldRetryEmptyCompletion(c, info) -+ recordEmptyCompletion(c, info, &usage, "empty_responses_output", responsesOutputTypes(&responsesResponse), willRetry) -+ if willRetry { -+ return nil, newEmptyCompletionRetryError() -+ } -+ } -+ if !emptyCompletion { -+ recordEmptyCompletionRetrySuccess(c, info, &usage) -+ } -+ -+ service.IOCopyBytesGracefully(c, resp, responseBody) -+ - if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil { - return &usage, nil - } -@@ -81,8 +92,28 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - - var usage = &dto.Usage{} - var responseTextBuilder strings.Builder -+ var streamErr *types.MaxAPIError -+ type pendingResponsesStreamEvent struct { -+ response dto.ResponsesStreamResponse -+ data string -+ } -+ pendingEvents := make([]pendingResponsesStreamEvent, 0, 4) -+ streamForwarded := false -+ hasVisiblePayload := false -+ -+ flushPendingEvents := func() { -+ for _, event := range pendingEvents { -+ sendResponsesStreamData(c, event.response, event.data) -+ } -+ pendingEvents = pendingEvents[:0] -+ streamForwarded = true -+ } - - helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { -+ if streamErr != nil { -+ sr.Stop(streamErr) -+ return -+ } - - // 检查当前数据是否包含 completed 状态和 usage 信息 - var streamResponse dto.ResponsesStreamResponse -@@ -91,7 +122,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - sr.Error(err) - return - } -- sendResponsesStreamData(c, streamResponse, data) -+ if responsesStreamHasVisibleOutput(&streamResponse) { -+ hasVisiblePayload = true -+ } - switch streamResponse.Type { - case "response.completed": - if streamResponse.Response != nil { -@@ -115,6 +148,15 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - c.Set("image_generation_call_size", streamResponse.Response.GetSize()) - } - } -+ if !hasVisiblePayload { -+ willRetry := shouldRetryEmptyCompletion(c, info) -+ recordEmptyCompletion(c, info, usage, "empty_responses_stream_output", responsesOutputTypes(streamResponse.Response), willRetry) -+ if willRetry { -+ streamErr = newEmptyCompletionRetryError() -+ sr.Stop(streamErr) -+ return -+ } -+ } - case "response.output_text.delta": - // 处理输出文本 - responseTextBuilder.WriteString(streamResponse.Delta) -@@ -131,8 +173,33 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - } - } - } -+ -+ if streamForwarded { -+ sendResponsesStreamData(c, streamResponse, data) -+ return -+ } -+ -+ pendingEvents = append(pendingEvents, pendingResponsesStreamEvent{ -+ response: streamResponse, -+ data: data, -+ }) -+ switch streamResponse.Type { -+ case "response.completed", "response.error", "response.failed": -+ flushPendingEvents() -+ default: -+ if hasVisiblePayload { -+ flushPendingEvents() -+ } -+ } - }) - -+ if streamErr != nil { -+ return nil, streamErr -+ } -+ if !streamForwarded && len(pendingEvents) > 0 { -+ flushPendingEvents() -+ } -+ - if usage.CompletionTokens == 0 { - // 计算输出文本的 token 数量 - tempStr := responseTextBuilder.String() -@@ -151,6 +218,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp - if service.ResponseAuditEnabled() { - service.SetRelayResponseAuditContent(info, responseTextBuilder.String()) - } -+ if hasVisiblePayload { -+ recordEmptyCompletionRetrySuccess(c, info, usage) -+ } - - return usage, nil - } -diff --git a/router/api-router.go b/router/api-router.go -index 9e2c2f0..9de0b9f 100644 ---- a/router/api-router.go -+++ b/router/api-router.go -@@ -312,6 +312,8 @@ func SetApiRouter(router *gin.Engine) { - logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) - logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) - logRoute.GET("/self/search", middleware.UserAuth(), middleware.SearchRateLimit(), controller.SearchUserLogs) -+ logRoute.GET("/detail/:id", middleware.AdminAuth(), controller.GetLogDetail) -+ logRoute.GET("/self/detail/:id", middleware.UserAuth(), controller.GetUserLogDetail) - logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteOldLogsCompat) - - systemTaskRoute := apiRouter.Group("/system-task") -diff --git a/service/billing_session.go b/service/billing_session.go -index d06e8e6..2d2eaf6 100644 ---- a/service/billing_session.go -+++ b/service/billing_session.go -@@ -50,10 +50,13 @@ func (s *BillingSession) Settle(actualQuota int) error { - return nil - } - // 1) 调整资金来源(仅在尚未提交时执行,防止重复调用) -+ appliedFundingDelta := int64(delta) - if !s.fundingSettled { -- if err := s.funding.Settle(delta); err != nil { -+ applied, err := s.funding.Settle(delta) -+ if err != nil { - return err - } -+ appliedFundingDelta = applied - s.fundingSettled = true - } - // 2) 调整令牌额度 -@@ -72,7 +75,7 @@ func (s *BillingSession) Settle(actualQuota int) error { - } - // 3) 更新 relayInfo 上的订阅 PostDelta(用于日志) - if s.funding.Source() == BillingSourceSubscription { -- s.relayInfo.SubscriptionPostDelta += int64(delta) -+ s.relayInfo.SubscriptionPostDelta += appliedFundingDelta - } - s.settled = true - return tokenErr -@@ -109,7 +112,7 @@ func (s *BillingSession) Refund(c *gin.Context) { - common.SysLog("error refunding billing source: " + err.Error()) - } - if extraReserved > 0 && funding.Source() == BillingSourceSubscription && subscriptionId > 0 { -- if err := model.PostConsumeUserSubscriptionDelta(subscriptionId, -int64(extraReserved)); err != nil { -+ if _, err := model.PostConsumeUserSubscriptionDelta(subscriptionId, -int64(extraReserved)); err != nil { - common.SysLog("error refunding subscription extra reserved quota: " + err.Error()) - } - } -@@ -238,7 +241,7 @@ func (s *BillingSession) reserveFunding(delta int) error { - funding.consumed += delta - return nil - case *SubscriptionFunding: -- if err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, int64(delta)); err != nil { -+ if _, err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, int64(delta)); err != nil { - return types.NewErrorWithStatusCode( - fmt.Errorf("订阅额度不足或未配置订阅: %s", err.Error()), - types.ErrorCodeInsufficientUserQuota, -@@ -262,7 +265,7 @@ func (s *BillingSession) rollbackFundingReserve(delta int) { - funding.consumed -= delta - } - case *SubscriptionFunding: -- if err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, -int64(delta)); err != nil { -+ if _, err := model.PostConsumeUserSubscriptionDelta(funding.subscriptionId, -int64(delta)); err != nil { - common.SysLog("error rolling back subscription funding reserve: " + err.Error()) - } - } -diff --git a/service/channel.go b/service/channel.go -index 2bbc466..7bf5b62 100644 ---- a/service/channel.go -+++ b/service/channel.go -@@ -49,6 +49,9 @@ func ShouldDisableChannel(err *types.MaxAPIError) bool { - if err == nil { - return false - } -+ if err.GetErrorCode() == types.ErrorCodeEmptyResponse { -+ return false -+ } - if types.IsChannelError(err) { - return true - } -diff --git a/service/funding_source.go b/service/funding_source.go -index 4b94466..9803ae2 100644 ---- a/service/funding_source.go -+++ b/service/funding_source.go -@@ -17,7 +17,7 @@ type FundingSource interface { - // PreConsume 从该资金来源预扣 amount 额度 - PreConsume(amount int) error - // Settle 根据差额调整资金来源(正数补扣,负数退还) -- Settle(delta int) error -+ Settle(delta int) (int64, error) - // Refund 退还所有预扣费 - Refund() error - } -@@ -44,14 +44,14 @@ func (w *WalletFunding) PreConsume(amount int) error { - return nil - } - --func (w *WalletFunding) Settle(delta int) error { -+func (w *WalletFunding) Settle(delta int) (int64, error) { - if delta == 0 { -- return nil -+ return 0, nil - } - if delta > 0 { -- return model.DecreaseUserQuota(w.userId, delta, false) -+ return int64(delta), model.DecreaseUserQuota(w.userId, delta, false) - } -- return model.IncreaseUserQuota(w.userId, -delta, false) -+ return int64(delta), model.IncreaseUserQuota(w.userId, -delta, false) - } - - func (w *WalletFunding) Refund() error { -@@ -101,9 +101,9 @@ func (s *SubscriptionFunding) PreConsume(_ int) error { - return nil - } - --func (s *SubscriptionFunding) Settle(delta int) error { -+func (s *SubscriptionFunding) Settle(delta int) (int64, error) { - if delta == 0 { -- return nil -+ return 0, nil - } - return model.PostConsumeUserSubscriptionDelta(s.subscriptionId, int64(delta)) - } -diff --git a/service/log_info_generate.go b/service/log_info_generate.go -index 4a21602..4f67560 100644 ---- a/service/log_info_generate.go -+++ b/service/log_info_generate.go -@@ -72,8 +72,10 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m - - AppendChannelAffinityAdminInfo(ctx, adminInfo) - AppendContentAuditAdminInfo(relayInfo, adminInfo) -+ appendEmptyCompletionAdminInfo(ctx, adminInfo) - - other["admin_info"] = adminInfo -+ appendEmptyCompletionInfo(ctx, other) - appendRequestPath(ctx, relayInfo, other) - appendRequestConversionChain(relayInfo, other) - appendFinalRequestFormat(relayInfo, other) -@@ -83,6 +85,59 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m - return other - } - -+func appendEmptyCompletionAdminInfo(ctx *gin.Context, adminInfo map[string]interface{}) { -+ if ctx == nil || adminInfo == nil { -+ return -+ } -+ info, ok := common.GetContextKeyType[map[string]any](ctx, constant.ContextKeyEmptyCompletionInfo) -+ if !ok || len(info) == 0 { -+ return -+ } -+ adminInfo["empty_completion"] = info -+} -+ -+func appendEmptyCompletionInfo(ctx *gin.Context, other map[string]interface{}) { -+ if ctx == nil || other == nil { -+ return -+ } -+ info, ok := common.GetContextKeyType[map[string]any](ctx, constant.ContextKeyEmptyCompletionInfo) -+ if !ok || len(info) == 0 { -+ return -+ } -+ copyBool := func(key string) { -+ if v, ok := info[key].(bool); ok { -+ other[key] = v -+ } -+ } -+ copyInt := func(key string) { -+ if v, ok := info[key].(int); ok { -+ other[key] = v -+ } -+ } -+ copyString := func(key string) { -+ if v, ok := info[key].(string); ok && v != "" { -+ other[key] = v -+ } -+ } -+ copyBool("empty_completion") -+ copyBool("empty_retry") -+ copyInt("empty_retry_count") -+ copyString("empty_retry_result") -+ copyString("empty_reason") -+ if v, ok := info["empty_output_types"].([]string); ok && len(v) > 0 { -+ other["empty_output_types"] = v -+ } -+ copyInt("first_empty_channel_id") -+ copyInt("last_empty_channel_id") -+ copyInt("first_empty_channel_type") -+ copyInt("last_empty_channel_type") -+ copyInt("first_empty_retry_index") -+ copyInt("last_empty_retry_index") -+ copyInt("final_channel_id") -+ copyInt("final_channel_type") -+ copyInt("final_retry_index") -+} -+ - func appendParamOverrideInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) { - if relayInfo == nil || other == nil || len(relayInfo.ParamOverrideAudit) == 0 { - return -diff --git a/service/openaicompat/responses_to_chat.go b/service/openaicompat/responses_to_chat.go -index 533b16d..b1d0def 100644 ---- a/service/openaicompat/responses_to_chat.go -+++ b/service/openaicompat/responses_to_chat.go -@@ -116,6 +116,8 @@ func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { - for _, c := range out.Content { - if c.Type == "output_text" && c.Text != "" { - sb.WriteString(c.Text) -+ } else if c.Refusal != "" { -+ sb.WriteString(c.Refusal) - } - } - } -@@ -126,6 +128,8 @@ func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string { - for _, c := range out.Content { - if c.Text != "" { - sb.WriteString(c.Text) -+ } else if c.Refusal != "" { -+ sb.WriteString(c.Refusal) - } - } - } -diff --git a/service/quota.go b/service/quota.go -index b470d96..09fe4f6 100644 ---- a/service/quota.go -+++ b/service/quota.go -@@ -412,10 +412,11 @@ func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQu - } - delta := int64(quota) - if delta != 0 { -- if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil { -+ appliedDelta, err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta) -+ if err != nil { - return err - } -- relayInfo.SubscriptionPostDelta += delta -+ relayInfo.SubscriptionPostDelta += appliedDelta - } - } else { - // Wallet -diff --git a/service/task_billing.go b/service/task_billing.go -index e7db358..ab8e32a 100644 ---- a/service/task_billing.go -+++ b/service/task_billing.go -@@ -92,7 +92,8 @@ func taskIsSubscription(task *model.Task) bool { - // taskAdjustFunding 调整任务的资金来源(钱包或订阅),delta > 0 表示扣费,delta < 0 表示退还。 - func taskAdjustFunding(task *model.Task, delta int) error { - if taskIsSubscription(task) { -- return model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta)) -+ _, err := model.PostConsumeUserSubscriptionDelta(task.PrivateData.SubscriptionId, int64(delta)) -+ return err - } - if delta > 0 { - return model.DecreaseUserQuota(task.UserId, delta, false) diff --git a/review_other.diff b/review_other.diff deleted file mode 100644 index 848283a..0000000 --- a/review_other.diff +++ /dev/null @@ -1,2025 +0,0 @@ -diff --git a/.gitignore b/.gitignore -index 621abf7..d4bb9a3 100644 ---- a/.gitignore -+++ b/.gitignore -@@ -26,6 +26,7 @@ plans - .cursor - .agents - .codex -+.tmp - - electron/node_modules - electron/dist -diff --git a/web/default/src/components/layout/components/mobile-drawer.tsx b/web/default/src/components/layout/components/mobile-drawer.tsx -index 7471c9b..8e85545 100644 ---- a/web/default/src/components/layout/components/mobile-drawer.tsx -+++ b/web/default/src/components/layout/components/mobile-drawer.tsx -@@ -259,21 +259,37 @@ export function MobileDrawer({ - - ) : ( - -- {mobileLinksList.map((link, index) => ( -- -- { -+ const className = -+ 'text-primary/60 hover:text-primary/80 transition-colors' -+ return ( -+ -- {link.title} -- -- -- ))} -+ {link.external ? ( -+ -+ {link.title} -+ -+ ) : ( -+ -+ {link.title} -+ -+ )} -+ -+ ) -+ })} - - )} - -diff --git a/web/default/src/components/layout/components/public-header.tsx b/web/default/src/components/layout/components/public-header.tsx -index 5aa19ce..5146416 100644 ---- a/web/default/src/components/layout/components/public-header.tsx -+++ b/web/default/src/components/layout/components/public-header.tsx -@@ -149,6 +149,11 @@ export function PublicHeader(props: PublicHeaderProps) { - navigate({ to: '/sign-in', search: { redirect } }) - }, [authPromptTarget?.href, navigate]) - -+ const getLinkTitle = useCallback( -+ (link: TopNavLink) => (link.literalTitle ? link.title : t(link.title)), -+ [t] -+ ) -+ - const handleNavLinkClick = useCallback( - ( - event: React.MouseEvent, -@@ -167,7 +172,7 @@ export function PublicHeader(props: PublicHeaderProps) { - } - setAuthPromptSecondsLeft(AUTH_PROMPT_SECONDS) - setAuthPromptTarget({ -- title: t(link.title), -+ title: getLinkTitle(link), - href: link.href, - }) - return -@@ -177,7 +182,7 @@ export function PublicHeader(props: PublicHeaderProps) { - setMobileOpen(false) - } - }, -- [t] -+ [getLinkTitle] - ) - - return ( -@@ -240,7 +245,7 @@ export function PublicHeader(props: PublicHeaderProps) { - link.disabled && 'pointer-events-none opacity-50' - )} - > -- {t(link.title)} -+ {getLinkTitle(link)} - - ) - } -@@ -258,7 +263,7 @@ export function PublicHeader(props: PublicHeaderProps) { - link.disabled && 'pointer-events-none opacity-50' - )} - > -- {t(link.title)} -+ {getLinkTitle(link)} - - ) - })} -@@ -379,7 +384,7 @@ export function PublicHeader(props: PublicHeaderProps) { - className={linkClassName} - style={transitionStyle} - > -- {t(link.title)} -+ {getLinkTitle(link)} - - ) - } -@@ -392,7 +397,7 @@ export function PublicHeader(props: PublicHeaderProps) { - className={linkClassName} - style={transitionStyle} - > -- {t(link.title)} -+ {getLinkTitle(link)} - - ) - })} -diff --git a/web/default/src/components/layout/types.ts b/web/default/src/components/layout/types.ts -index 81113b2..12b079e 100644 ---- a/web/default/src/components/layout/types.ts -+++ b/web/default/src/components/layout/types.ts -@@ -91,6 +91,7 @@ export type TopNavLink = { - disabled?: boolean - requiresAuth?: boolean - external?: boolean -+ literalTitle?: boolean - } - - /** -diff --git a/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx b/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx -index 7f5411c..137267f 100644 ---- a/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx -+++ b/web/default/src/features/redemption-codes/components/data-table-bulk-actions.tsx -@@ -51,7 +51,7 @@ export function DataTableBulkActions({ - const contentToCopy = useMemo(() => { - const selectedCodes = selectedRows.map((row) => { - const redemption = row.original as Redemption -- return `${redemption.name}\t${redemption.key}` -+ return redemption.key - }) - return selectedCodes.join('\n') - }, [selectedRows]) -diff --git a/web/default/src/features/system-settings/general/system-behavior-section.tsx b/web/default/src/features/system-settings/general/system-behavior-section.tsx -index 6e2ad24..1421a1e 100644 ---- a/web/default/src/features/system-settings/general/system-behavior-section.tsx -+++ b/web/default/src/features/system-settings/general/system-behavior-section.tsx -@@ -44,6 +44,7 @@ import { safeNumberFieldProps } from '../utils/numeric-field' - - const behaviorSchema = z.object({ - RetryTimes: z.coerce.number().min(0).max(10), -+ EmptyCompletionRetryEnabled: z.boolean(), - DefaultCollapseSidebar: z.boolean(), - DemoSiteEnabled: z.boolean(), - SelfUseModeEnabled: z.boolean(), -@@ -108,6 +109,29 @@ export function SystemBehaviorSection({ - )} - /> - -+ ( -+ -+ -+ {t('Enable Empty Completion Retry')} -+ -+ {t( -+ 'Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.' -+ )} -+ -+ -+ -+ -+ -+ -+ )} -+ /> -+ - -@@ -79,6 +84,18 @@ const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ - config.about === undefined - ? HEADER_NAV_DEFAULT.about - : Boolean(config.about), -+ customEnabled: -+ config.custom?.enabled === undefined -+ ? HEADER_NAV_DEFAULT.custom.enabled -+ : Boolean(config.custom.enabled), -+ customTitle: -+ config.custom?.title === undefined -+ ? HEADER_NAV_DEFAULT.custom.title -+ : config.custom.title, -+ customHref: -+ config.custom?.href === undefined -+ ? HEADER_NAV_DEFAULT.custom.href -+ : config.custom.href, - }) - - export function HeaderNavigationSection({ -@@ -102,6 +119,12 @@ export function HeaderNavigationSection({ - console: values.console, - docs: values.docs, - about: values.about, -+ custom: { -+ ...(current.custom ?? HEADER_NAV_DEFAULT.custom), -+ enabled: values.customEnabled, -+ title: values.customTitle.trim(), -+ href: values.customHref.trim(), -+ }, - pricing: { - ...(current.pricing ?? HEADER_NAV_DEFAULT.pricing), - enabled: values.pricingEnabled, -@@ -113,6 +136,7 @@ export function HeaderNavigationSection({ - const resetToDefault = () => { - form.reset(toFormValues(HEADER_NAV_DEFAULT)) - } -+ const customEnabled = form.watch('customEnabled') - - const simpleModules: Array<{ - key: keyof HeaderNavFormValues -@@ -188,7 +212,7 @@ export function HeaderNavigationSection({ - - - - -@@ -213,7 +237,7 @@ export function HeaderNavigationSection({ - - - - -@@ -236,7 +260,7 @@ export function HeaderNavigationSection({ - - - -@@ -249,6 +273,76 @@ export function HeaderNavigationSection({ - - ))} - -+ -+ -+ ( -+ -+ -+ {t('Custom navigation item')} -+ -+ {t('Show one custom link in the top navigation.')} -+ -+ -+ -+ -+ -+ -+ -+ )} -+ /> -+ -+ -+
-+ ( -+ -+ {t('Navigation name')} -+ -+ -+ -+ -+ {t('Displayed label in the top navigation.')} -+ -+ -+ -+ )} -+ /> -+ -+ ( -+ -+ {t('Navigation link')} -+ -+ -+ -+ -+ {t('Destination path or URL.')} -+ -+ -+ -+ )} -+ /> -+
-+
-+
- - - -diff --git a/web/default/src/features/system-settings/operations/index.tsx b/web/default/src/features/system-settings/operations/index.tsx -index 0e98a1b..900769a 100644 ---- a/web/default/src/features/system-settings/operations/index.tsx -+++ b/web/default/src/features/system-settings/operations/index.tsx -@@ -27,6 +27,7 @@ import { - - const defaultOperationsSettings: OperationsSettings = { - RetryTimes: 0, -+ EmptyCompletionRetryEnabled: false, - DefaultCollapseSidebar: false, - DemoSiteEnabled: false, - SelfUseModeEnabled: false, -diff --git a/web/default/src/features/system-settings/operations/section-registry.tsx b/web/default/src/features/system-settings/operations/section-registry.tsx -index f5ecc52..30983f7 100644 ---- a/web/default/src/features/system-settings/operations/section-registry.tsx -+++ b/web/default/src/features/system-settings/operations/section-registry.tsx -@@ -34,6 +34,7 @@ const OPERATIONS_SECTIONS = [ - ( - const paramRecord = params as unknown as Record - const queryParams = buildQueryParams({ - p: paramRecord.p || 1, -- page_size: paramRecord.page_size || 20, -+ page_size: paramRecord.page_size || 100, - ...params, - }) - const path = buildApiPath(endpoint, isAdmin) -@@ -76,6 +77,18 @@ export const getUserLogs = ( - params: Omit = {} - ) => fetchLogs('/api/log', params, false) - -+export async function getLogDetail(id: number): Promise { -+ const res = await api.get(`/api/log/detail/${id}`) -+ return res.data -+} -+ -+export async function getUserLogDetail( -+ id: number -+): Promise { -+ const res = await api.get(`/api/log/self/detail/${id}`) -+ return res.data -+} -+ - export const getLogStats = (params: GetLogStatsParams = {}) => - fetchLogStats('/api/log', params, true) - -diff --git a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx -index fd4a50f..84e1cc1 100644 ---- a/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx -+++ b/web/default/src/features/usage-logs/components/common-logs-filter-bar.tsx -@@ -17,7 +17,7 @@ along with this program. If not, see . - For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues - */ - import { useState, useEffect, useCallback, useMemo } from 'react' --import { useQueryClient, useIsFetching } from '@tanstack/react-query' -+import { useIsFetching } from '@tanstack/react-query' - import { useNavigate, getRouteApi } from '@tanstack/react-router' - import { type Table } from '@tanstack/react-table' - import { Eye, EyeOff } from 'lucide-react' -@@ -70,7 +70,6 @@ export function CommonLogsFilterBar( - ) { - const { t } = useTranslation() - const navigate = useNavigate() -- const queryClient = useQueryClient() - const searchParams = route.useSearch() - const isAdmin = useIsAdmin() - const { status } = useStatus() -@@ -140,12 +139,12 @@ export function CommonLogsFilterBar( - search: { - ...filterParams, - type: [logType], -+ pageSize: props.table.getState().pagination.pageSize, - page: 1, -+ searchVersion: Date.now(), - }, - }) -- queryClient.invalidateQueries({ queryKey: ['logs'] }) -- queryClient.invalidateQueries({ queryKey: ['usage-logs-stats'] }) -- }, [filters, logType, navigate, queryClient]) -+ }, [filters, logType, navigate, props.table]) - - const handleReset = useCallback(() => { - const { start, end } = getDefaultTimeRange() -@@ -158,14 +157,14 @@ export function CommonLogsFilterBar( - params: { section: 'common' }, - search: { - page: 1, -+ pageSize: 100, - type: [LOG_TYPE_ALL_VALUE], - startTime: start.getTime(), - endTime: end.getTime(), -+ searchVersion: undefined, - }, - }) -- queryClient.invalidateQueries({ queryKey: ['logs'] }) -- queryClient.invalidateQueries({ queryKey: ['usage-logs-stats'] }) -- }, [navigate, queryClient]) -+ }, [navigate]) - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { -diff --git a/web/default/src/features/usage-logs/components/common-logs-stats.tsx b/web/default/src/features/usage-logs/components/common-logs-stats.tsx -index 67c38b9..f4cf231 100644 ---- a/web/default/src/features/usage-logs/components/common-logs-stats.tsx -+++ b/web/default/src/features/usage-logs/components/common-logs-stats.tsx -@@ -51,9 +51,11 @@ export function CommonLogsStats() { - const isAdmin = useIsAdmin() - const searchParams = route.useSearch() - const { sensitiveVisible } = useUsageLogsContext() -+ const hasExecutedSearch = searchParams.searchVersion != null - - const { data: stats, isLoading } = useQuery({ - queryKey: ['usage-logs-stats', isAdmin, searchParams], -+ enabled: hasExecutedSearch, - queryFn: async () => { - const params = buildApiParams({ - page: 1, -@@ -74,6 +76,10 @@ export function CommonLogsStats() { - placeholderData: (previousData) => previousData, - }) - -+ if (!hasExecutedSearch) { -+ return null -+ } -+ - if (isLoading) { - return ( -
-diff --git a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx -index 7511aee..60131ca 100644 ---- a/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx -+++ b/web/default/src/features/usage-logs/components/dialogs/details-dialog.tsx -@@ -16,6 +16,7 @@ along with this program. If not, see . - - For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues - */ -+import { useQuery } from '@tanstack/react-query' - import { - Copy, - Check, -@@ -47,6 +48,7 @@ import { Label } from '@/components/ui/label' - import { ScrollArea } from '@/components/ui/scroll-area' - import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge' - import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown' -+import { getLogDetail, getUserLogDetail } from '../../api' - import { - parseLogOther, - getParamOverrideActionLabel, -@@ -445,8 +447,40 @@ interface DetailsDialogProps { - export function DetailsDialog(props: DetailsDialogProps) { - const { t } = useTranslation() - const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false }) -- const other = parseLogOther(props.log.other) -- const details = renderAuditContent(other, t) ?? props.log.content ?? '' -+ const detailLoadErrorMessage = t('Failed to load log details') -+ const detailLogId = props.log.log_id ?? props.log.id -+ const shouldLoadDetail = props.open && detailLogId > 0 -+ const { data: detailResult } = useQuery({ -+ queryKey: [ -+ 'usage-log-detail', -+ props.isAdmin, -+ detailLogId, -+ detailLoadErrorMessage, -+ ], -+ enabled: shouldLoadDetail, -+ staleTime: 5 * 60 * 1000, -+ retry: false, -+ queryFn: async () => { -+ const result = props.isAdmin -+ ? await getLogDetail(detailLogId) -+ : await getUserLogDetail(detailLogId) -+ if (!result.success) { -+ throw new Error(result.message || detailLoadErrorMessage) -+ } -+ return result -+ }, -+ }) -+ const log = detailResult?.data -+ ? { -+ ...props.log, -+ ...detailResult.data, -+ channel_name: -+ detailResult.data.channel_name || props.log.channel_name || undefined, -+ log_id: props.log.log_id ?? detailResult.data.log_id, -+ } -+ : props.log -+ const other = parseLogOther(log.other) -+ const details = renderAuditContent(other, t) ?? log.content ?? '' - const typeConfig = getLogTypeConfig(props.log.type) - - const isViolation = isViolationFeeLog(other) -@@ -580,10 +614,10 @@ export function DetailsDialog(props: DetailsDialogProps) { - value={ - - {props.log.channel} -- {props.log.channel_name && ( -+ {log.channel_name && ( - - {' '} -- ({props.log.channel_name}) -+ ({log.channel_name}) - - )} - -diff --git a/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx b/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx -index 1c50f50..8e79029 100644 ---- a/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx -+++ b/web/default/src/features/usage-logs/components/task-logs-filter-bar.tsx -@@ -17,7 +17,7 @@ along with this program. If not, see . - For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues - */ - import { useState, useEffect, useCallback } from 'react' --import { useQueryClient, useIsFetching } from '@tanstack/react-query' -+import { useIsFetching } from '@tanstack/react-query' - import { useNavigate, getRouteApi } from '@tanstack/react-router' - import { type Table } from '@tanstack/react-table' - import { useTranslation } from 'react-i18next' -@@ -66,7 +66,6 @@ function setFilterValue( - export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) { - const { t } = useTranslation() - const navigate = useNavigate() -- const queryClient = useQueryClient() - const searchParams = route.useSearch() - const isAdmin = useIsAdmin() - const fetchingLogs = useIsFetching({ queryKey: ['logs'] }) -@@ -98,6 +97,7 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) { - ...(searchParams.filter ? { taskId: searchParams.filter } : {}), - } - -+ // eslint-disable-next-line react-hooks/set-state-in-effect - setFilters(next) - }, [ - props.logCategory, -@@ -121,11 +121,12 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) { - params: { section: props.logCategory }, - search: { - ...filterParams, -+ pageSize: props.table.getState().pagination.pageSize, - page: 1, -+ searchVersion: Date.now(), - }, - }) -- queryClient.invalidateQueries({ queryKey: ['logs'] }) -- }, [filters, navigate, props.logCategory, queryClient]) -+ }, [filters, navigate, props.logCategory, props.table]) - - const handleReset = useCallback(() => { - const { start, end } = getDefaultTimeRange() -@@ -137,12 +138,13 @@ export function TaskLogsFilterBar(props: TaskLogsFilterBarProps) { - params: { section: props.logCategory }, - search: { - page: 1, -+ pageSize: 100, - startTime: start.getTime(), - endTime: end.getTime(), -+ searchVersion: undefined, - }, - }) -- queryClient.invalidateQueries({ queryKey: ['logs'] }) -- }, [navigate, props.logCategory, queryClient]) -+ }, [navigate, props.logCategory]) - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { -diff --git a/web/default/src/features/usage-logs/components/usage-logs-table.tsx b/web/default/src/features/usage-logs/components/usage-logs-table.tsx -index 0d7dbe6..136cced 100644 ---- a/web/default/src/features/usage-logs/components/usage-logs-table.tsx -+++ b/web/default/src/features/usage-logs/components/usage-logs-table.tsx -@@ -29,7 +29,6 @@ import { - getPaginationRowModel, - useReactTable, - } from '@tanstack/react-table' --import { useMediaQuery } from '@/hooks' - import { useTranslation } from 'react-i18next' - import { toast } from 'sonner' - import { cn } from '@/lib/utils' -@@ -68,8 +67,8 @@ interface UsageLogsTableProps { - export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { - const { t } = useTranslation() - const isAdmin = useIsAdmin() -- const isMobile = useMediaQuery('(max-width: 640px)') - const searchParams = route.useSearch() -+ const hasExecutedSearch = searchParams.searchVersion != null - - const { - columnFilters, -@@ -80,7 +79,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { - } = useTableUrlState({ - search: route.useSearch(), - navigate: route.useNavigate(), -- pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 }, -+ pagination: { defaultPage: 1, defaultPageSize: 100 }, - globalFilter: { enabled: false }, - columnFilters: [ - { -@@ -120,6 +119,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { - searchParams, - t, - ], -+ enabled: hasExecutedSearch, - queryFn: async () => { - const result = await fetchLogsByCategory({ - logCategory, -@@ -138,6 +138,9 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { - return result.data || DEFAULT_LOGS_DATA - }, - placeholderData: (previousData, previousQuery) => { -+ if (!hasExecutedSearch) { -+ return undefined -+ } - if (previousQuery?.queryKey[1] === logCategory) { - return previousData - } -@@ -148,6 +151,14 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { - const logs = data?.items || [] - const columns = useColumnsByCategory(logCategory, isAdmin) - const isLoadingData = isLoading || (isFetching && !data) -+ const emptyTitle = hasExecutedSearch -+ ? t('No Logs Found') -+ : t('Click Search to load logs') -+ const emptyDescription = hasExecutedSearch -+ ? t( -+ 'No usage logs available. Logs will appear here once API calls are made.' -+ ) -+ : t('Set filters and click Search to load usage logs.') - - const table = useReactTable({ - data: logs as unknown as Record[], -@@ -182,10 +193,8 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { - columns={columns as ColumnDef>[]} - isLoading={isLoadingData} - isFetching={isFetching} -- emptyTitle={t('No Logs Found')} -- emptyDescription={t( -- 'No usage logs available. Logs will appear here once API calls are made.' -- )} -+ emptyTitle={emptyTitle} -+ emptyDescription={emptyDescription} - skeletonKeyPrefix='usage-log-skeleton' - tableClassName={cn( - 'overflow-x-auto', -diff --git a/web/default/src/features/usage-logs/types.ts b/web/default/src/features/usage-logs/types.ts -index c307a6d..cac1452 100644 ---- a/web/default/src/features/usage-logs/types.ts -+++ b/web/default/src/features/usage-logs/types.ts -@@ -41,6 +41,7 @@ export interface UsageLog { - channel: number - channel_name?: string - token_id?: number -+ log_id?: number - group: string - ip?: string - request_id?: string -@@ -321,6 +322,12 @@ export interface GetLogsResponse { - } - } - -+export interface GetLogDetailResponse { -+ success: boolean -+ message?: string -+ data?: UsageLog -+} -+ - export interface GetLogStatsParams { - type?: number - username?: string -diff --git a/web/default/src/features/users/api.ts b/web/default/src/features/users/api.ts -index 04d65ee..85ec882 100644 ---- a/web/default/src/features/users/api.ts -+++ b/web/default/src/features/users/api.ts -@@ -54,6 +54,7 @@ export async function searchUsers( - group = '', - role = '', - status = '', -+ quota_status = '', - p = 1, - page_size = 10, - } = params -@@ -62,6 +63,7 @@ export async function searchUsers( - queryParams.set('group', group) - if (role) queryParams.set('role', role) - if (status) queryParams.set('status', status) -+ if (quota_status) queryParams.set('quota_status', quota_status) - queryParams.set('p', String(p)) - queryParams.set('page_size', String(page_size)) - const res = await api.get(`/api/user/search?${queryParams.toString()}`) -diff --git a/web/default/src/features/users/components/users-columns.tsx b/web/default/src/features/users/components/users-columns.tsx -index c8e1c2b..c9bfe1d 100644 ---- a/web/default/src/features/users/components/users-columns.tsx -+++ b/web/default/src/features/users/components/users-columns.tsx -@@ -34,6 +34,7 @@ import { StatusBadge } from '@/components/status-badge' - import { TableId } from '@/components/table-id' - import { - USER_STATUS, -+ USER_QUOTA_STATUS, - USER_STATUSES, - USER_ROLES, - isUserDeleted, -@@ -128,7 +129,7 @@ export function useUsersColumns(): ColumnDef[] { - { - accessorKey: 'status', - header: ({ column }) => ( -- -+ - ), - cell: ({ row }) => { - const user = row.original -@@ -163,7 +164,22 @@ export function useUsersColumns(): ColumnDef[] { - return value.includes(String(row.getValue(id))) - }, - enableSorting: false, -- meta: { label: t('Status'), mobileBadge: true }, -+ meta: { label: t('Account Status'), mobileBadge: true }, -+ }, -+ { -+ id: 'quota_status', -+ accessorFn: (user) => { -+ if (user.quota < 0) return USER_QUOTA_STATUS.NEGATIVE -+ if (user.quota === 0) return USER_QUOTA_STATUS.ZERO -+ return USER_QUOTA_STATUS.POSITIVE -+ }, -+ header: t('Balance Status'), -+ filterFn: (row, id, value) => { -+ return value.includes(String(row.getValue(id))) -+ }, -+ enableHiding: false, -+ enableSorting: false, -+ meta: { label: t('Balance Status'), mobileHidden: true }, - }, - { - id: 'quota', -@@ -178,6 +194,45 @@ export function useUsersColumns(): ColumnDef[] { - const total = used + remaining - const percentage = total > 0 ? (remaining / total) * 100 : 0 - -+ if (remaining < 0) { -+ return ( -+ -+ -+ } -+ > -+ -+ -+ {formatQuota(remaining)} -+ -+ -+ -+
-+
-+ {t('Current Balance')}: {formatQuota(remaining)} -+
-+
-+ {t('Used:')} {formatQuota(used)} -+
-+
-+ {t('Total Granted')}: {formatQuota(total)} -+
-+
-+ {t( -+ 'May be caused by concurrent billing or manual adjustment.' -+ )} -+
-+
-+
-+
-+ ) -+ } -+ - if (total === 0) { - return ( - [] { - return ( - - } -+ render={ -+
-+ } - > -
- -diff --git a/web/default/src/features/users/components/users-table.tsx b/web/default/src/features/users/components/users-table.tsx -index 5d30c86..0a9a545 100644 ---- a/web/default/src/features/users/components/users-table.tsx -+++ b/web/default/src/features/users/components/users-table.tsx -@@ -42,6 +42,7 @@ import { - import { getUsers, searchUsers } from '../api' - import { - USER_STATUS, -+ getUserQuotaStatusOptions, - getUserStatusOptions, - getUserRoleOptions, - isUserDeleted, -@@ -64,7 +65,9 @@ export function UsersTable() { - const isMobile = useMediaQuery('(max-width: 640px)') - const [rowSelection, setRowSelection] = useState({}) - const [sorting, setSorting] = useState([]) -- const [columnVisibility, setColumnVisibility] = useState({}) -+ const [columnVisibility, setColumnVisibility] = useState({ -+ quota_status: false, -+ }) - - const { - globalFilter, -@@ -81,6 +84,11 @@ export function UsersTable() { - globalFilter: { enabled: true, key: 'filter' }, - columnFilters: [ - { columnId: 'status', searchKey: 'status', type: 'array' }, -+ { -+ columnId: 'quota_status', -+ searchKey: 'quota_status', -+ type: 'array', -+ }, - { columnId: 'role', searchKey: 'role', type: 'array' }, - { columnId: 'group', searchKey: 'group', type: 'string' }, - ], -@@ -89,6 +97,10 @@ export function UsersTable() { - (columnFilters.find((filter) => filter.id === 'status')?.value as - | string[] - | undefined) ?? [] -+ const quotaStatusFilter = -+ (columnFilters.find((filter) => filter.id === 'quota_status')?.value as -+ | string[] -+ | undefined) ?? [] - const roleFilter = - (columnFilters.find((filter) => filter.id === 'role')?.value as - | string[] -@@ -97,6 +109,7 @@ export function UsersTable() { - (columnFilters.find((filter) => filter.id === 'group')?.value as string) ?? - '' - const selectedStatus = statusFilter[0] ?? '' -+ const selectedQuotaStatus = quotaStatusFilter[0] ?? '' - const selectedRole = roleFilter[0] ?? '' - - // Fetch data with React Query -@@ -107,6 +120,7 @@ export function UsersTable() { - pagination.pageSize, - globalFilter, - selectedStatus, -+ selectedQuotaStatus, - selectedRole, - groupFilter, - refreshTrigger, -@@ -114,7 +128,10 @@ export function UsersTable() { - queryFn: async () => { - const hasFilter = globalFilter?.trim() - const hasColumnFilter = -- Boolean(selectedStatus) || Boolean(selectedRole) || Boolean(groupFilter) -+ Boolean(selectedStatus) || -+ Boolean(selectedQuotaStatus) || -+ Boolean(selectedRole) || -+ Boolean(groupFilter) - const params = { - p: pagination.pageIndex + 1, - page_size: pagination.pageSize, -@@ -126,6 +143,7 @@ export function UsersTable() { - ...params, - keyword: globalFilter, - status: selectedStatus, -+ quota_status: selectedQuotaStatus, - role: selectedRole, - group: groupFilter, - }) -@@ -211,10 +229,16 @@ export function UsersTable() { - filters: [ - { - columnId: 'status', -- title: t('Status'), -+ title: t('Account Status'), - options: getUserStatusOptions(t), - singleSelect: true, - }, -+ { -+ columnId: 'quota_status', -+ title: t('Balance Status'), -+ options: getUserQuotaStatusOptions(t), -+ singleSelect: true, -+ }, - { - columnId: 'role', - title: t('Role'), -diff --git a/web/default/src/features/users/constants.ts b/web/default/src/features/users/constants.ts -index fb4e198..63335a8 100644 ---- a/web/default/src/features/users/constants.ts -+++ b/web/default/src/features/users/constants.ts -@@ -61,6 +61,25 @@ export const getUserStatusOptions = (t: (key: string) => string) => [ - { label: t('Deleted'), value: String(USER_STATUS.DELETED) }, - ] - -+// ============================================================================ -+// User Quota Status Configuration -+// ============================================================================ -+ -+export const USER_QUOTA_STATUS = { -+ NEGATIVE: 'negative', -+ ZERO: 'zero', -+ POSITIVE: 'positive', -+} as const -+ -+export type UserQuotaStatus = -+ (typeof USER_QUOTA_STATUS)[keyof typeof USER_QUOTA_STATUS] -+ -+export const getUserQuotaStatusOptions = (t: (key: string) => string) => [ -+ { label: t('Negative Balance'), value: USER_QUOTA_STATUS.NEGATIVE }, -+ { label: t('Zero Balance'), value: USER_QUOTA_STATUS.ZERO }, -+ { label: t('Positive Balance'), value: USER_QUOTA_STATUS.POSITIVE }, -+] -+ - // ============================================================================ - // User Role Configuration - // ============================================================================ -diff --git a/web/default/src/features/users/types.ts b/web/default/src/features/users/types.ts -index c03349f..1fc6cd0 100644 ---- a/web/default/src/features/users/types.ts -+++ b/web/default/src/features/users/types.ts -@@ -94,6 +94,7 @@ export interface SearchUsersParams { - group?: string - role?: string - status?: string -+ quota_status?: string - p?: number - page_size?: number - } -diff --git a/web/default/src/hooks/use-top-nav-links.ts b/web/default/src/hooks/use-top-nav-links.ts -index 02f8a05..7a2834c 100644 ---- a/web/default/src/hooks/use-top-nav-links.ts -+++ b/web/default/src/hooks/use-top-nav-links.ts -@@ -28,6 +28,15 @@ export type TopNavLink = { - disabled?: boolean - requiresAuth?: boolean - external?: boolean -+ literalTitle?: boolean -+} -+ -+function isExternalHref(href: string): boolean { -+ return /^https?:\/\//i.test(href) -+} -+ -+function isSupportedHref(href: string): boolean { -+ return href.startsWith('/') || isExternalHref(href) - } - - /** -@@ -39,7 +48,8 @@ export type TopNavLink = { - * pricing: { enabled: true, requireAuth: false }, - * rankings: { enabled: true, requireAuth: false }, - * docs: true, -- * about: true -+ * about: true, -+ * custom: { enabled: false, title: "", href: "" } - * } - */ - export function useTopNavLinks(): TopNavLink[] { -@@ -99,5 +109,19 @@ export function useTopNavLinks(): TopNavLink[] { - links.push({ title: t('About'), href: '/about' }) - } - -+ const custom = modules?.custom -+ if (custom?.enabled) { -+ const title = custom.title.trim() -+ const href = custom.href.trim() -+ if (title && href && isSupportedHref(href)) { -+ links.push({ -+ title, -+ href, -+ external: isExternalHref(href), -+ literalTitle: true, -+ }) -+ } -+ } -+ - return links - } -diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json -index 414470c..dd0943d 100644 ---- a/web/default/src/i18n/locales/en.json -+++ b/web/default/src/i18n/locales/en.json -@@ -133,6 +133,7 @@ - "Account deleted successfully": "Account deleted successfully", - "Account ID *": "Account ID *", - "Account Info": "Account Info", -+ "Account Status": "Account Status", - "Account used when authenticating with the SMTP server": "Account used when authenticating with the SMTP server", - "acknowledge the related legal risks": "acknowledge the related legal risks", - "Across all groups": "Across all groups", -@@ -590,6 +591,7 @@ - "Balance depleted": "Balance depleted", - "Balance is shown in quota units": "Balance is shown in quota units", - "Balance queried successfully": "Balance queried successfully", -+ "Balance Status": "Balance Status", - "Balance updated successfully": "Balance updated successfully", - "Balance updated: {{balance}}": "Balance updated: {{balance}}", - "Bar Chart": "Bar Chart", -@@ -861,6 +863,7 @@ - "Click for details": "Click for details", - "Click save when you're done.": "Click save when you're done.", - "Click save when you're done.": "Click save when you're done.", -+ "Click Search to load logs": "Click Search to load logs", - "Click the button below to bind your Telegram account": "Click the button below to bind your Telegram account", - "Click to open deployment": "Click to open deployment", - "Click to preview audio": "Click to preview audio", -@@ -1215,6 +1218,7 @@ - "Custom model (comma-separated)": "Custom model (comma-separated)", - "Custom module": "Custom module", - "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.", -+ "Custom navigation item": "Custom navigation item", - "Custom OAuth": "Custom OAuth", - "Custom OAuth Providers": "Custom OAuth Providers", - "Custom Seconds": "Custom Seconds", -@@ -1333,6 +1337,7 @@ - "Designed and Developed by": "Designed and Developed by", - "designed for scale": "designed for scale", - "Designed for the continuous operation of AI models and Agents": "Designed for the continuous operation of AI models and Agents", -+ "Destination path or URL.": "Destination path or URL.", - "Destroyed": "Destroyed", - "Detailed request logs for investigations.": "Detailed request logs for investigations.", - "Details": "Details", -@@ -1403,6 +1408,7 @@ - "Display Options": "Display Options", - "Display Token Statistics": "Display Token Statistics", - "Displayed in": "Displayed in", -+ "Displayed label in the top navigation.": "Displayed label in the top navigation.", - "Displays the mobile sidebar.": "Displays the mobile sidebar.", - "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.", - "Do not repeat check-in; only once per day": "Do not repeat check-in; only once per day", -@@ -1538,6 +1544,7 @@ - "Enable Discord OAuth": "Enable Discord OAuth", - "Enable Disk Cache": "Enable Disk Cache", - "Enable drawing features": "Enable drawing features", -+ "Enable Empty Completion Retry": "Enable Empty Completion Retry", - "Enable filtering": "Enable filtering", - "Enable FunctionCall thoughtSignature Fill": "Enable FunctionCall thoughtSignature Fill", - "Enable GitHub OAuth": "Enable GitHub OAuth", -@@ -1807,6 +1814,7 @@ - "Failed to load home page content": "Failed to load home page content", - "Failed to load image": "Failed to load image", - "Failed to load key status": "Failed to load key status", -+ "Failed to load log details": "Failed to load log details", - "Failed to load logs": "Failed to load logs", - "Failed to load Passkey status": "Failed to load Passkey status", - "Failed to load playground groups": "Failed to load playground groups", -@@ -2561,6 +2569,7 @@ - "Maximum tokens including hidden reasoning tokens": "Maximum tokens including hidden reasoning tokens", - "Maximum tokens per response": "Maximum tokens per response", - "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647", -+ "May be caused by concurrent billing or manual adjustment.": "May be caused by concurrent billing or manual adjustment.", - "May be used for training by upstream provider": "May be used for training by upstream provider", - "Media pricing": "Media pricing", - "Median time-to-first-token (TTFT) sampled hourly per group": "Median time-to-first-token (TTFT) sampled hourly per group", -@@ -2762,8 +2771,12 @@ - "name@example.com": "name@example.com", - "Native format": "Native format", - "Native forwarding": "Native forwarding", -+ "Navigation link": "Navigation link", -+ "Navigation name": "Navigation name", - "Need a redemption code?": "Need a redemption code?", - "Needs API key": "Needs API key", -+ "Negative": "Negative", -+ "Negative Balance": "Negative Balance", - "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.", - "Nested JSON: source group →": "Nested JSON: source group →", - "Network proxy for this channel (supports socks5 protocol)": "Network proxy for this channel (supports socks5 protocol)", -@@ -3344,6 +3357,7 @@ - "Port": "Port", - "Port must be a positive integer": "Port must be a positive integer", - "Position in the stack": "Position in the stack", -+ "Positive Balance": "Positive Balance", - "Post Delta": "Post Delta", - "PostgreSQL detected": "PostgreSQL detected", - "PostgreSQL offers advanced reliability and data integrity for production workloads.": "PostgreSQL offers advanced reliability and data integrity for production workloads.", -@@ -3781,6 +3795,7 @@ - "Retention days": "Retention days", - "Retry": "Retry", - "Retry Chain": "Retry Chain", -+ "Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.": "Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.", - "Retry policy": "Retry policy", - "Retry Suggestion": "Retry Suggestion", - "Retry Times": "Retry Times", -@@ -4063,6 +4078,7 @@ - "Set API key access restrictions": "Set API key access restrictions", - "Set API key basic information": "Set API key basic information", - "Set Field": "Set Field", -+ "Set filters and click Search to load usage logs.": "Set filters and click Search to load usage logs.", - "Set filters to customize your dashboard statistics and charts.": "Set filters to customize your dashboard statistics and charts.", - "Set filters to narrow down your log search results.": "Set filters to narrow down your log search results.", - "Set Header": "Set Header", -@@ -4097,6 +4113,7 @@ - "Show": "Show", - "Show All": "Show All", - "Show all providers including unbound": "Show all providers including unbound", -+ "Show one custom link in the top navigation.": "Show one custom link in the top navigation.", - "Show only bound providers": "Show only bound providers", - "Show prices in currency instead of quota.": "Show prices in currency instead of quota.", - "Show setup guide": "Show setup guide", -@@ -4603,6 +4620,7 @@ - "Total earned": "Total earned", - "Total Earned": "Total Earned", - "Total GPUs": "Total GPUs", -+ "Total Granted": "Total Granted", - "Total invitation revenue": "Total invitation revenue", - "Total Log Size": "Total Log Size", - "Total Quota": "Total Quota", -@@ -5087,6 +5105,7 @@ - "Your transaction history will appear here": "Your transaction history will appear here", - "Your Turnstile secret key": "Your Turnstile secret key", - "Your Turnstile site key": "Your Turnstile site key", -+ "Zero Balance": "Zero Balance", - "Zero retention": "Zero retention", - "Zhipu": "Zhipu", - "Zhipu V4": "Zhipu V4", -diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json -index 1f4e5dd..f617796 100644 ---- a/web/default/src/i18n/locales/fr.json -+++ b/web/default/src/i18n/locales/fr.json -@@ -133,6 +133,7 @@ - "Account deleted successfully": "Compte supprimé avec succès", - "Account ID *": "ID de compte *", - "Account Info": "Informations du compte", -+ "Account Status": "Statut du compte", - "Account used when authenticating with the SMTP server": "Compte utilisé lors de l'authentification auprès du serveur SMTP", - "acknowledge the related legal risks": "reconnais les risques juridiques associés", - "Across all groups": "Tous groupes confondus", -@@ -590,6 +591,7 @@ - "Balance depleted": "Solde épuisé", - "Balance is shown in quota units": "Le solde est affiché en unités de quota", - "Balance queried successfully": "Solde interrogé avec succès", -+ "Balance Status": "Statut du solde", - "Balance updated successfully": "Solde mis à jour avec succès", - "Balance updated: {{balance}}": "Solde mis à jour : {{balance}}", - "Bar Chart": "Graphique en barres", -@@ -861,6 +863,7 @@ - "Click for details": "Cliquez pour les détails", - "Click save when you're done.": "Cliquez sur Enregistrer lorsque vous avez terminé.", - "Click save when you're done.": "Cliquez sur Enregistrer lorsque vous avez terminé.", -+ "Click Search to load logs": "Cliquez sur Rechercher pour charger les journaux", - "Click the button below to bind your Telegram account": "Cliquez sur le bouton ci-dessous pour lier votre compte Telegram", - "Click to open deployment": "Cliquez pour ouvrir le déploiement", - "Click to preview audio": "Cliquer pour prévisualiser l'audio", -@@ -1215,6 +1218,7 @@ - "Custom model (comma-separated)": "Modèle personnalisé (séparé par des virgules)", - "Custom module": "Module personnalisé", - "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Multiplicateurs personnalisés lorsque des groupes d'utilisateurs spécifiques utilisent des groupes de jetons spécifiques. Exemple : les utilisateurs VIP obtiennent un taux de 0,9x lorsqu'ils utilisent les jetons du groupe \"edit_this\".", -+ "Custom navigation item": "Élément de navigation personnalisé", - "Custom OAuth": "OAuth personnalisé", - "Custom OAuth Providers": "Fournisseurs OAuth personnalisés", - "Custom Seconds": "Secondes personnalisées", -@@ -1333,6 +1337,7 @@ - "Designed and Developed by": "Conçu et développé par", - "designed for scale": "conçu pour la scalabilité", - "Designed for the continuous operation of AI models and Agents": "Designed for the continuous operation of AI models and Agents", -+ "Destination path or URL.": "Chemin ou URL de destination.", - "Destroyed": "Détruit", - "Detailed request logs for investigations.": "Journaux détaillés des requêtes pour les enquêtes.", - "Details": "Détails", -@@ -1403,6 +1408,7 @@ - "Display Options": "Options d'affichage", - "Display Token Statistics": "Afficher les statistiques des jetons", - "Displayed in": "Affiché en", -+ "Displayed label in the top navigation.": "Libellé affiché dans la navigation supérieure.", - "Displays the mobile sidebar.": "Affiche la barre latérale mobile.", - "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Ne faites pas trop confiance à cette fonctionnalité. L'IP peut être usurpée. Veuillez l'utiliser avec nginx, CDN et autres passerelles.", - "Do not repeat check-in; only once per day": "Ne répétez pas le check-in ; une seule fois par jour", -@@ -1538,6 +1544,7 @@ - "Enable Discord OAuth": "Activer OAuth Discord", - "Enable Disk Cache": "Activer le cache disque", - "Enable drawing features": "Activer les fonctionnalités de dessin", -+ "Enable Empty Completion Retry": "Activer la relance des complétions vides", - "Enable filtering": "Activer le filtrage", - "Enable FunctionCall thoughtSignature Fill": "Activer le remplissage de thoughtSignature pour FunctionCall", - "Enable GitHub OAuth": "Activer GitHub OAuth", -@@ -1807,6 +1814,7 @@ - "Failed to load home page content": "Échec du chargement du contenu de la page d'accueil", - "Failed to load image": "Échec du chargement de l'image", - "Failed to load key status": "Échec du chargement du statut des clés", -+ "Failed to load log details": "Échec du chargement des détails du journal", - "Failed to load logs": "Échec du chargement des journaux", - "Failed to load Passkey status": "Échec du chargement du statut Passkey", - "Failed to load playground groups": "Échec du chargement des groupes du playground", -@@ -2561,6 +2569,7 @@ - "Maximum tokens including hidden reasoning tokens": "Jetons maximum, y compris les jetons de raisonnement masqués", - "Maximum tokens per response": "Nombre maximal de jetons par réponse", - "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, les deux ≤ 2 147 483 647", -+ "May be caused by concurrent billing or manual adjustment.": "Peut être causé par une facturation concurrente ou un ajustement manuel.", - "May be used for training by upstream provider": "Peut être utilisé pour l'entraînement par le fournisseur amont", - "Media pricing": "Tarification multimédia", - "Median time-to-first-token (TTFT) sampled hourly per group": "Latence médiane jusqu'au premier jeton (TTFT) échantillonnée par heure et par groupe", -@@ -2762,8 +2771,12 @@ - "name@example.com": "name@example.com", - "Native format": "Format natif", - "Native forwarding": "Transfert natif", -+ "Navigation link": "Lien de navigation", -+ "Navigation name": "Nom de navigation", - "Need a redemption code?": "Besoin d'un code d'échange ?", - "Needs API key": "Clé API requise", -+ "Negative": "Négatif", -+ "Negative Balance": "Solde négatif", - "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON imbriqué définissant des règles par groupe pour ajouter (+:), supprimer (-:), ou ajouter des groupes utilisables.", - "Nested JSON: source group →": "JSON imbriqué : groupe source →", - "Network proxy for this channel (supports socks5 protocol)": "Proxy réseau pour ce canal (supporte le protocole socks5)", -@@ -3344,6 +3357,7 @@ - "Port": "Port", - "Port must be a positive integer": "Le port doit être un entier positif", - "Position in the stack": "Position dans la pile", -+ "Positive Balance": "Solde positif", - "Post Delta": "Delta post-calcul", - "PostgreSQL detected": "PostgreSQL détecté", - "PostgreSQL offers advanced reliability and data integrity for production workloads.": "PostgreSQL offre une fiabilité avancée et une intégrité des données pour les charges de travail en production.", -@@ -3781,6 +3795,7 @@ - "Retention days": "Jours de rétention", - "Retry": "Réessayer", - "Retry Chain": "Chaîne de tentatives", -+ "Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.": "Réessaie une fois lorsque l'amont renvoie une complétion Chat ou Responses vide avant l'envoi de données de flux. Nécessite un nombre de tentatives supérieur à 0.", - "Retry policy": "Politique de reprise", - "Retry Suggestion": "Suggestion de relance", - "Retry Times": "Nombre de tentatives", -@@ -4063,6 +4078,7 @@ - "Set API key access restrictions": "Définir les restrictions d'accès de la clé API", - "Set API key basic information": "Définir les informations de base de la clé API", - "Set Field": "Définir le champ", -+ "Set filters and click Search to load usage logs.": "Définissez les filtres puis cliquez sur Rechercher pour charger les journaux d'utilisation.", - "Set filters to customize your dashboard statistics and charts.": "Définir des filtres pour personnaliser les statistiques et les graphiques de votre tableau de bord.", - "Set filters to narrow down your log search results.": "Définir des filtres pour affiner vos résultats de recherche de journaux.", - "Set Header": "Définir l'en-tête", -@@ -4097,6 +4113,7 @@ - "Show": "Afficher", - "Show All": "Tout afficher", - "Show all providers including unbound": "Afficher tous les fournisseurs (y compris non liés)", -+ "Show one custom link in the top navigation.": "Afficher un lien personnalisé dans la navigation supérieure.", - "Show only bound providers": "Afficher uniquement les fournisseurs liés", - "Show prices in currency instead of quota.": "Afficher les prix en devise au lieu du quota.", - "Show setup guide": "Afficher le guide de configuration", -@@ -4603,6 +4620,7 @@ - "Total earned": "Total gagné", - "Total Earned": "Total gagné", - "Total GPUs": "GPUs totaux", -+ "Total Granted": "Quota total accordé", - "Total invitation revenue": "Revenu total des invitations", - "Total Log Size": "Taille totale des journaux", - "Total Quota": "Quota total", -@@ -5087,6 +5105,7 @@ - "Your transaction history will appear here": "Votre historique de transactions apparaîtra ici", - "Your Turnstile secret key": "Votre clé secrète Turnstile", - "Your Turnstile site key": "Votre clé de site Turnstile", -+ "Zero Balance": "Solde nul", - "Zero retention": "Aucune rétention", - "Zhipu": "Zhipu", - "Zhipu V4": "Zhipu V4", -diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json -index 54042d8..981e037 100644 ---- a/web/default/src/i18n/locales/ja.json -+++ b/web/default/src/i18n/locales/ja.json -@@ -133,6 +133,7 @@ - "Account deleted successfully": "アカウントが正常に削除されました", - "Account ID *": "アカウントID *", - "Account Info": "アカウント情報", -+ "Account Status": "アカウント状態", - "Account used when authenticating with the SMTP server": "SMTPサーバーで認証する際に使用されるアカウント", - "acknowledge the related legal risks": "関連する法的リスクを認識します", - "Across all groups": "全グループを通じて", -@@ -590,6 +591,7 @@ - "Balance depleted": "残高なし", - "Balance is shown in quota units": "残高はクォータ単位で表示されます", - "Balance queried successfully": "残高の取得に成功しました", -+ "Balance Status": "残高状態", - "Balance updated successfully": "残高が正常に更新されました", - "Balance updated: {{balance}}": "残高更新:{{balance}}", - "Bar Chart": "棒グラフ", -@@ -861,6 +863,7 @@ - "Click for details": "クリックして詳細を表示", - "Click save when you're done.": "完了したら「保存」をクリックしてください。", - "Click save when you're done.": "完了したら保存をクリックしてください。", -+ "Click Search to load logs": "検索をクリックしてログを読み込む", - "Click the button below to bind your Telegram account": "下のボタンをクリックしてTelegramアカウントをバインドしてください", - "Click to open deployment": "クリックして展開を開く", - "Click to preview audio": "クリックして音声をプレビュー", -@@ -1215,6 +1218,7 @@ - "Custom model (comma-separated)": "カスタムモデル (コンマ区切り)", - "Custom module": "カスタムモジュール", - "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "特定のユーザーグループが特定のトークングループを使用する場合のカスタム乗数。例: VIPユーザーが「edit_this」グループトークンを使用する場合、0.9倍のレートが適用されます。", -+ "Custom navigation item": "カスタムナビゲーション項目", - "Custom OAuth": "カスタム OAuth", - "Custom OAuth Providers": "カスタムOAuthプロバイダー", - "Custom Seconds": "カスタム秒数", -@@ -1333,6 +1337,7 @@ - "Designed and Developed by": "設計・開発", - "designed for scale": "スケールのために設計", - "Designed for the continuous operation of AI models and Agents": "Designed for the continuous operation of AI models and Agents", -+ "Destination path or URL.": "移動先のパスまたは URL。", - "Destroyed": "破棄済み", - "Detailed request logs for investigations.": "調査のための詳細なリクエストログ。", - "Details": "詳細", -@@ -1403,6 +1408,7 @@ - "Display Options": "表示オプション", - "Display Token Statistics": "トークン統計を表示", - "Displayed in": "表示単位", -+ "Displayed label in the top navigation.": "トップナビゲーションに表示されるラベルです。", - "Displays the mobile sidebar.": "モバイルサイドバーを表示します。", - "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "この機能を過信しないでください。IPは偽装される可能性があります。nginx、CDNなどのゲートウェイと併用してください。", - "Do not repeat check-in; only once per day": "チェックインを繰り返さないでください;1日1回のみ", -@@ -1538,6 +1544,7 @@ - "Enable Discord OAuth": "Discord OAuthを有効にする", - "Enable Disk Cache": "ディスクキャッシュを有効にする", - "Enable drawing features": "描画機能を有効にする", -+ "Enable Empty Completion Retry": "空の補完の再試行を有効化", - "Enable filtering": "フィルタリングを有効にする", - "Enable FunctionCall thoughtSignature Fill": "FunctionCall用のthoughtSignature自動付与を有効化", - "Enable GitHub OAuth": "GitHub OAuthを有効にする", -@@ -1807,6 +1814,7 @@ - "Failed to load home page content": "ホームページの内容の読み込みに失敗しました", - "Failed to load image": "画像の読み込みに失敗しました", - "Failed to load key status": "キー状態の読み込みに失敗しました", -+ "Failed to load log details": "ログ詳細の読み込みに失敗しました", - "Failed to load logs": "ログの読み込みに失敗しました", - "Failed to load Passkey status": "Passkeyのステータスの読み込みに失敗しました", - "Failed to load playground groups": "Playground グループの読み込みに失敗しました", -@@ -2561,6 +2569,7 @@ - "Maximum tokens including hidden reasoning tokens": "隠れ推論トークンを含む最大トークン数", - "Maximum tokens per response": "1 回の応答あたりの最大トークン数", - "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0、maxSuccess ≥ 1、両方とも ≤ 2,147,483,647", -+ "May be caused by concurrent billing or manual adjustment.": "同時課金または手動調整によって発生した可能性があります。", - "May be used for training by upstream provider": "上流プロバイダーが学習に利用する可能性があります", - "Media pricing": "メディア料金", - "Median time-to-first-token (TTFT) sampled hourly per group": "グループ別に毎時サンプリングした最初のトークンまでの中央値レイテンシ (TTFT)", -@@ -2762,8 +2771,12 @@ - "name@example.com": "name@example.com", - "Native format": "ネイティブ形式", - "Native forwarding": "ネイティブ転送", -+ "Navigation link": "ナビゲーションリンク", -+ "Navigation name": "ナビゲーション名", - "Need a redemption code?": "引き換えコードが必要ですか?", - "Needs API key": "API キーが必要", -+ "Negative": "マイナス", -+ "Negative Balance": "マイナス残高", - "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "追加 (+:)、削除 (-:)、または使用可能なグループの追加を行うグループごとのルールを定義するネストされたJSON。", - "Nested JSON: source group →": "ネストされたJSON: ソースグループ →", - "Network proxy for this channel (supports socks5 protocol)": "このチャネルのネットワークプロキシ (socks5プロトコルをサポート)", -@@ -3344,6 +3357,7 @@ - "Port": "ポート", - "Port must be a positive integer": "ポートは正の整数でなければなりません", - "Position in the stack": "スタック内の位置", -+ "Positive Balance": "プラス残高", - "Post Delta": "事後差額", - "PostgreSQL detected": "PostgreSQLが検出されました", - "PostgreSQL offers advanced reliability and data integrity for production workloads.": "PostgreSQL は本番ワークロード向けの高度な信頼性とデータ整合性を提供します。", -@@ -3781,6 +3795,7 @@ - "Retention days": "保持日数", - "Retry": "再試行", - "Retry Chain": "リトライチェーン", -+ "Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.": "ストリームデータを送信する前に上流が空の Chat または Responses 補完を返した場合に 1 回だけ再試行します。Retry Times は 0 より大きくする必要があります。", - "Retry policy": "リトライポリシー", - "Retry Suggestion": "リトライ提案", - "Retry Times": "再試行回数", -@@ -4063,6 +4078,7 @@ - "Set API key access restrictions": "API キーのアクセス制限を設定", - "Set API key basic information": "API キーの基本情報を設定", - "Set Field": "フィールドを設定", -+ "Set filters and click Search to load usage logs.": "フィルターを設定し、検索をクリックして使用ログを読み込んでください。", - "Set filters to customize your dashboard statistics and charts.": "ダッシュボードの統計とグラフをカスタマイズするためにフィルターを設定します。", - "Set filters to narrow down your log search results.": "ログ検索結果を絞り込むためにフィルターを設定します。", - "Set Header": "ヘッダーを設定", -@@ -4097,6 +4113,7 @@ - "Show": "表示", - "Show All": "すべて表示", - "Show all providers including unbound": "未バインドを含むすべてのプロバイダーを表示", -+ "Show one custom link in the top navigation.": "トップナビゲーションにカスタムリンクを1つ表示します。", - "Show only bound providers": "バインド済みのプロバイダーのみ表示", - "Show prices in currency instead of quota.": "クォータではなく通貨で価格を表示。", - "Show setup guide": "セットアップガイドを表示", -@@ -4603,6 +4620,7 @@ - "Total earned": "总收入", - "Total Earned": "総収益", - "Total GPUs": "合計 GPU", -+ "Total Granted": "付与総額", - "Total invitation revenue": "招待による総収益", - "Total Log Size": "ログ合計サイズ", - "Total Quota": "合計クォータ", -@@ -5087,6 +5105,7 @@ - "Your transaction history will appear here": "取引履歴はここに表示されます", - "Your Turnstile secret key": "あなたのTurnstileシークレットキー", - "Your Turnstile site key": "あなたのTurnstileサイトキー", -+ "Zero Balance": "ゼロ残高", - "Zero retention": "データ保持なし", - "Zhipu": "Zhipu", - "Zhipu V4": "Zhipu V 4", -diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json -index f9a9f56..d2ca6cd 100644 ---- a/web/default/src/i18n/locales/ru.json -+++ b/web/default/src/i18n/locales/ru.json -@@ -133,6 +133,7 @@ - "Account deleted successfully": "Аккаунт успешно удалён", - "Account ID *": "ID аккаунта *", - "Account Info": "Информация об аккаунте", -+ "Account Status": "Статус аккаунта", - "Account used when authenticating with the SMTP server": "Учетная запись, используемая при аутентификации с SMTP-сервером", - "acknowledge the related legal risks": "признаю связанные правовые риски", - "Across all groups": "По всем группам", -@@ -590,6 +591,7 @@ - "Balance depleted": "Баланс исчерпан", - "Balance is shown in quota units": "Баланс показан в единицах квоты", - "Balance queried successfully": "Баланс успешно запрошен", -+ "Balance Status": "Статус баланса", - "Balance updated successfully": "Баланс успешно обновлён", - "Balance updated: {{balance}}": "Баланс обновлён: {{balance}}", - "Bar Chart": "Столбчатая диаграмма", -@@ -861,6 +863,7 @@ - "Click for details": "Нажмите для подробностей", - "Click save when you're done.": "Нажмите «Сохранить», когда закончите.", - "Click save when you're done.": "Нажмите сохранить, когда закончите.", -+ "Click Search to load logs": "Нажмите «Поиск», чтобы загрузить логи", - "Click the button below to bind your Telegram account": "Нажмите кнопку ниже, чтобы привязать ваш аккаунт Telegram", - "Click to open deployment": "Нажмите, чтобы открыть развертывание", - "Click to preview audio": "Нажмите для предпросмотра аудио", -@@ -1215,6 +1218,7 @@ - "Custom model (comma-separated)": "Пользовательская модель (через запятую)", - "Custom module": "Пользовательский модуль", - "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Пользовательские множители, когда определенные группы пользователей используют определенные группы токенов. Пример: VIP-пользователи получают ставку 0.9x при использовании токенов группы \"edit_this\".", -+ "Custom navigation item": "Пользовательский пункт навигации", - "Custom OAuth": "Пользовательский OAuth", - "Custom OAuth Providers": "Пользовательские OAuth-провайдеры", - "Custom Seconds": "Пользовательские секунды", -@@ -1333,6 +1337,7 @@ - "Designed and Developed by": "Разработано и создано", - "designed for scale": "спроектировано для масштабирования", - "Designed for the continuous operation of AI models and Agents": "Designed for the continuous operation of AI models and Agents", -+ "Destination path or URL.": "Путь назначения или URL.", - "Destroyed": "Уничтожено", - "Detailed request logs for investigations.": "Подробные журналы запросов для расследований.", - "Details": "Детали", -@@ -1403,6 +1408,7 @@ - "Display Options": "Параметры отображения", - "Display Token Statistics": "Показать статистику токенов", - "Displayed in": "Отображается в", -+ "Displayed label in the top navigation.": "Метка, отображаемая в верхней навигации.", - "Displays the mobile sidebar.": "Отображает мобильную боковую панель.", - "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Не доверяйте этой функции слишком сильно. IP может быть подделан. Используйте с nginx, CDN и другими шлюзами.", - "Do not repeat check-in; only once per day": "Не повторяйте отметку; только один раз в день", -@@ -1538,6 +1544,7 @@ - "Enable Discord OAuth": "Включить Discord OAuth", - "Enable Disk Cache": "Включить дисковый кэш", - "Enable drawing features": "Включить функции рисования", -+ "Enable Empty Completion Retry": "Включить повтор пустых завершений", - "Enable filtering": "Включить фильтрацию", - "Enable FunctionCall thoughtSignature Fill": "Включить автозаполнение thoughtSignature для FunctionCall", - "Enable GitHub OAuth": "Включить GitHub OAuth", -@@ -1807,6 +1814,7 @@ - "Failed to load home page content": "Не удалось загрузить содержимое главной страницы", - "Failed to load image": "Не удалось загрузить изображение", - "Failed to load key status": "Не удалось загрузить статус ключей", -+ "Failed to load log details": "Не удалось загрузить сведения лога", - "Failed to load logs": "Не удалось загрузить логи", - "Failed to load Passkey status": "Не удалось загрузить статус Passkey", - "Failed to load playground groups": "Не удалось загрузить группы Playground", -@@ -2561,6 +2569,7 @@ - "Maximum tokens including hidden reasoning tokens": "Максимум токенов с учётом скрытых reasoning-токенов", - "Maximum tokens per response": "Максимум токенов на ответ", - "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, оба ≤ 2,147,483,647", -+ "May be caused by concurrent billing or manual adjustment.": "Может быть вызвано параллельным списанием или ручной корректировкой.", - "May be used for training by upstream provider": "Может использоваться поставщиком для обучения", - "Media pricing": "Цены для медиа", - "Median time-to-first-token (TTFT) sampled hourly per group": "Медианная задержка первого токена (TTFT), измеряемая ежечасно по группам", -@@ -2762,8 +2771,12 @@ - "name@example.com": "name@example.com", - "Native format": "Собственный формат", - "Native forwarding": "Нативная пересылка", -+ "Navigation link": "Ссылка навигации", -+ "Navigation name": "Название навигации", - "Need a redemption code?": "Нужен код активации?", - "Needs API key": "Нужен API-ключ", -+ "Negative": "Отрицательный", -+ "Negative Balance": "Отрицательный баланс", - "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "Вложенный JSON, определяющий правила для каждой группы для добавления (+:), удаления (-:) или добавления используемых групп.", - "Nested JSON: source group →": "Вложенный JSON: исходная группа →", - "Network proxy for this channel (supports socks5 protocol)": "Сетевой прокси для этого канала (поддерживает протокол socks5)", -@@ -3344,6 +3357,7 @@ - "Port": "Порт", - "Port must be a positive integer": "Порт должен быть положительным целым числом", - "Position in the stack": "Позиция в стеке", -+ "Positive Balance": "Положительный баланс", - "Post Delta": "Дельта после расчёта", - "PostgreSQL detected": "PostgreSQL обнаружен", - "PostgreSQL offers advanced reliability and data integrity for production workloads.": "PostgreSQL обеспечивает высокую надёжность и целостность данных для продакшен-нагрузок.", -@@ -3781,6 +3795,7 @@ - "Retention days": "Дней хранения", - "Retry": "Повторить попытку", - "Retry Chain": "Цепочка повторов", -+ "Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.": "Повторяет запрос один раз, если апстрим вернул пустое завершение Chat или Responses до отправки потоковых данных. Требуется, чтобы Retry Times было больше 0.", - "Retry policy": "Политика повторов", - "Retry Suggestion": "Рекомендация по повтору", - "Retry Times": "Количество повторных попыток", -@@ -4063,6 +4078,7 @@ - "Set API key access restrictions": "Настройте ограничения доступа API-ключа", - "Set API key basic information": "Настройте основные сведения API-ключа", - "Set Field": "Установить поле", -+ "Set filters and click Search to load usage logs.": "Задайте фильтры и нажмите «Поиск», чтобы загрузить логи использования.", - "Set filters to customize your dashboard statistics and charts.": "Установите фильтры, чтобы настроить статистику и диаграммы вашей панели управления.", - "Set filters to narrow down your log search results.": "Установите фильтры, чтобы сузить результаты поиска по журналам.", - "Set Header": "Установить заголовок", -@@ -4097,6 +4113,7 @@ - "Show": "Показать", - "Show All": "Показать все", - "Show all providers including unbound": "Показать всех провайдеров (включая непривязанные)", -+ "Show one custom link in the top navigation.": "Показывать одну пользовательскую ссылку в верхней навигации.", - "Show only bound providers": "Показать только привязанных провайдеров", - "Show prices in currency instead of quota.": "Показывать цены в валюте вместо квоты.", - "Show setup guide": "Показать руководство по настройке", -@@ -4603,6 +4620,7 @@ - "Total earned": "Итого заработано", - "Total Earned": "Всего заработано", - "Total GPUs": "Всего GPU", -+ "Total Granted": "Всего предоставлено", - "Total invitation revenue": "Общий доход от приглашений", - "Total Log Size": "Общий размер журналов", - "Total Quota": "Общая квота", -@@ -5087,6 +5105,7 @@ - "Your transaction history will appear here": "Ваша история транзакций появится здесь", - "Your Turnstile secret key": "Секретный ключ Turnstile", - "Your Turnstile site key": "Ключ сайта Turnstile", -+ "Zero Balance": "Нулевой баланс", - "Zero retention": "Без хранения данных", - "Zhipu": "Zhipu", - "Zhipu V4": "Zhipu V4", -diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json -index 2d8bb7f..8883192 100644 ---- a/web/default/src/i18n/locales/vi.json -+++ b/web/default/src/i18n/locales/vi.json -@@ -133,6 +133,7 @@ - "Account deleted successfully": "Tài khoản đã được xóa thành công", - "Account ID *": "ID tài khoản *", - "Account Info": "Thông tin tài khoản", -+ "Account Status": "Trạng thái tài khoản", - "Account used when authenticating with the SMTP server": "Tài khoản được sử dụng khi xác thực với máy chủ SMTP", - "acknowledge the related legal risks": "thừa nhận các rủi ro pháp lý liên quan", - "Across all groups": "Trên mọi nhóm", -@@ -590,6 +591,7 @@ - "Balance depleted": "Đã hết số dư", - "Balance is shown in quota units": "Số dư được hiển thị theo đơn vị hạn mức", - "Balance queried successfully": "Truy vấn số dư thành công", -+ "Balance Status": "Trạng thái số dư", - "Balance updated successfully": "Đã cập nhật số dư thành công", - "Balance updated: {{balance}}": "Số dư đã cập nhật: {{balance}}", - "Bar Chart": "Biểu đồ cột", -@@ -861,6 +863,7 @@ - "Click for details": "Nhấp để xem chi tiết", - "Click save when you're done.": "Nhấn lưu khi bạn hoàn tất.", - "Click save when you're done.": "Nhấn lưu khi bạn hoàn tất.", -+ "Click Search to load logs": "Nhấp Tìm kiếm để tải nhật ký", - "Click the button below to bind your Telegram account": "Nhấp vào nút bên dưới để liên kết tài khoản Telegram của bạn", - "Click to open deployment": "Nhấp để mở triển khai", - "Click to preview audio": "Nhấp để xem trước âm thanh", -@@ -1215,6 +1218,7 @@ - "Custom model (comma-separated)": "Mô hình tùy chỉnh (phân tách bằng dấu phẩy)", - "Custom module": "Module tùy chỉnh", - "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "Các hệ số nhân tùy chỉnh khi các nhóm người dùng cụ thể sử dụng các nhóm token cụ thể. Ví dụ: Người dùng VIP được hưởng tỷ lệ 0.9x khi sử dụng các token thuộc nhóm \"edit_this\".", -+ "Custom navigation item": "Mục điều hướng tùy chỉnh", - "Custom OAuth": "OAuth tùy chỉnh", - "Custom OAuth Providers": "Nhà cung cấp OAuth tùy chỉnh", - "Custom Seconds": "Giây tùy chỉnh", -@@ -1333,6 +1337,7 @@ - "Designed and Developed by": "Thiết kế và Phát triển bởi", - "designed for scale": "thiết kế cho quy mô lớn", - "Designed for the continuous operation of AI models and Agents": "Designed for the continuous operation of AI models and Agents", -+ "Destination path or URL.": "Đường dẫn hoặc URL đích.", - "Destroyed": "Đã hủy", - "Detailed request logs for investigations.": "Nhật ký yêu cầu chi tiết cho các cuộc điều tra.", - "Details": "Chi tiết", -@@ -1403,6 +1408,7 @@ - "Display Options": "Tùy chọn hiển thị", - "Display Token Statistics": "Hiển thị Thống kê Token", - "Displayed in": "Hiển thị theo", -+ "Displayed label in the top navigation.": "Nhãn hiển thị trên thanh điều hướng đầu trang.", - "Displays the mobile sidebar.": "Hiển thị thanh bên di động.", - "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "Đừng tin tưởng quá mức vào tính năng này. IP có thể bị giả mạo. Hãy sử dụng cùng với nginx, CDN và các gateway khác.", - "Do not repeat check-in; only once per day": "Không lặp lại check-in; chỉ một lần mỗi ngày", -@@ -1538,6 +1544,7 @@ - "Enable Discord OAuth": "Bật Discord OAuth", - "Enable Disk Cache": "Bật bộ nhớ đệm đĩa", - "Enable drawing features": "Bật tính năng vẽ", -+ "Enable Empty Completion Retry": "Bật thử lại khi phản hồi trống", - "Enable filtering": "Bật lọc", - "Enable FunctionCall thoughtSignature Fill": "Bật tính năng điền FunctionCall thoughtSignature", - "Enable GitHub OAuth": "Kích hoạt GitHub OAuth", -@@ -1807,6 +1814,7 @@ - "Failed to load home page content": "Không thể tải nội dung trang chủ", - "Failed to load image": "Không thể tải ảnh", - "Failed to load key status": "Không thể tải trạng thái khóa", -+ "Failed to load log details": "Không tải được chi tiết nhật ký", - "Failed to load logs": "Không tải được nhật ký", - "Failed to load Passkey status": "Không thể tải trạng thái Passkey", - "Failed to load playground groups": "Tải nhóm playground thất bại", -@@ -2561,6 +2569,7 @@ - "Maximum tokens including hidden reasoning tokens": "Số token tối đa bao gồm token suy luận ẩn", - "Maximum tokens per response": "Số token tối đa mỗi phản hồi", - "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1, cả hai đều ≤ 2,147,483,647", -+ "May be caused by concurrent billing or manual adjustment.": "Có thể do tính phí đồng thời hoặc điều chỉnh thủ công.", - "May be used for training by upstream provider": "Có thể được nhà cung cấp dùng để huấn luyện", - "Media pricing": "Giá phương tiện", - "Median time-to-first-token (TTFT) sampled hourly per group": "Độ trễ token đầu tiên trung vị (TTFT) lấy mẫu mỗi giờ theo nhóm", -@@ -2762,8 +2771,12 @@ - "name@example.com": "name@example.com", - "Native format": "Định dạng gốc", - "Native forwarding": "Chuyển tiếp nguyên bản", -+ "Navigation link": "Liên kết điều hướng", -+ "Navigation name": "Tên điều hướng", - "Need a redemption code?": "Cần mã đổi thưởng?", - "Needs API key": "Cần khóa API", -+ "Negative": "Âm", -+ "Negative Balance": "Số dư âm", - "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "JSON lồng nhau xác định quy tắc theo nhóm để thêm (+:), xóa (-:), hoặc nối các nhóm có thể sử dụng.", - "Nested JSON: source group →": "JSON lồng nhau: nhóm nguồn →", - "Network proxy for this channel (supports socks5 protocol)": "Proxy mạng cho kênh này (hỗ trợ giao thức socks5)", -@@ -3344,6 +3357,7 @@ - "Port": "Cảng", - "Port must be a positive integer": "Cổng phải là số nguyên dương", - "Position in the stack": "Vị trí trong stack", -+ "Positive Balance": "Số dư dương", - "Post Delta": "Chênh lệch sau", - "PostgreSQL detected": "Phát hiện PostgreSQL", - "PostgreSQL offers advanced reliability and data integrity for production workloads.": "PostgreSQL cung cấp độ tin cậy cao và tính toàn vẹn dữ liệu cho khối lượng công việc production.", -@@ -3781,6 +3795,7 @@ - "Retention days": "Số ngày lưu giữ", - "Retry": "Thử lại", - "Retry Chain": "Chuỗi thử lại", -+ "Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.": "Thử lại một lần khi upstream trả về phần hoàn thành Chat hoặc Responses trống trước khi gửi bất kỳ dữ liệu stream nào. Yêu cầu Retry Times lớn hơn 0.", - "Retry policy": "Chính sách retry", - "Retry Suggestion": "Gợi ý thử lại", - "Retry Times": "Số lần thử lại", -@@ -4063,6 +4078,7 @@ - "Set API key access restrictions": "Thiết lập hạn chế truy cập cho khóa API", - "Set API key basic information": "Thiết lập thông tin cơ bản cho khóa API", - "Set Field": "Đặt trường", -+ "Set filters and click Search to load usage logs.": "Thiết lập bộ lọc rồi nhấp Tìm kiếm để tải nhật ký sử dụng.", - "Set filters to customize your dashboard statistics and charts.": "Đặt bộ lọc để tùy chỉnh số liệu thống kê và biểu đồ trên bảng điều khiển của bạn.", - "Set filters to narrow down your log search results.": "Đặt bộ lọc để thu hẹp kết quả tìm kiếm nhật ký của bạn.", - "Set Header": "Đặt tiêu đề", -@@ -4097,6 +4113,7 @@ - "Show": "Hiển thị", - "Show All": "Hiển thị tất cả", - "Show all providers including unbound": "Hiển thị tất cả nhà cung cấp (bao gồm chưa liên kết)", -+ "Show one custom link in the top navigation.": "Hiển thị một liên kết tùy chỉnh trên thanh điều hướng đầu trang.", - "Show only bound providers": "Chỉ hiển thị nhà cung cấp đã liên kết", - "Show prices in currency instead of quota.": "Hiển thị giá bằng tiền tệ thay vì hạn ngạch.", - "Show setup guide": "Hiển thị hướng dẫn thiết lập", -@@ -4603,6 +4620,7 @@ - "Total earned": "Tổng thu nhập", - "Total Earned": "Total income", - "Total GPUs": "Tổng GPU", -+ "Total Granted": "Tổng hạn mức đã cấp", - "Total invitation revenue": "Tổng doanh thu mời", - "Total Log Size": "Tổng dung lượng nhật ký", - "Total Quota": "Tổng hạn mức", -@@ -5087,6 +5105,7 @@ - "Your transaction history will appear here": "Lịch sử giao dịch của bạn sẽ xuất hiện ở đây", - "Your Turnstile secret key": "Khóa bí mật Turnstile của bạn", - "Your Turnstile site key": "Khóa site Turnstile của bạn", -+ "Zero Balance": "Số dư bằng không", - "Zero retention": "Không lưu dữ liệu", - "Zhipu": "Zhipu", - "Zhipu V4": "Zhipu V4", -diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json -index 34bedf6..15ba178 100644 ---- a/web/default/src/i18n/locales/zh.json -+++ b/web/default/src/i18n/locales/zh.json -@@ -133,6 +133,7 @@ - "Account deleted successfully": "账户删除成功", - "Account ID *": "账户 ID *", - "Account Info": "账户信息", -+ "Account Status": "账号状态", - "Account used when authenticating with the SMTP server": "用于与 SMTP 服务器进行身份验证的账户", - "acknowledge the related legal risks": "确认相关法律风险", - "Across all groups": "跨所有分组", -@@ -590,6 +591,7 @@ - "Balance depleted": "余额已耗尽", - "Balance is shown in quota units": "余额以额度单位显示", - "Balance queried successfully": "余额查询成功", -+ "Balance Status": "余额状态", - "Balance updated successfully": "余额更新成功", - "Balance updated: {{balance}}": "余额已更新:{{balance}}", - "Bar Chart": "柱状图", -@@ -861,6 +863,7 @@ - "Click for details": "点击查看详情", - "Click save when you're done.": "完成後點擊保存。", - "Click save when you're done.": "完成后点击保存。", -+ "Click Search to load logs": "点击搜索加载日志", - "Click the button below to bind your Telegram account": "点击下方按钮绑定您的 Telegram 账户", - "Click to open deployment": "点击打开部署", - "Click to preview audio": "点击预览音乐", -@@ -1215,6 +1218,7 @@ - "Custom model (comma-separated)": "自定义模型(逗号分隔)", - "Custom module": "自定义模块", - "Custom multipliers when specific user groups use specific token groups. Example: VIP users get 0.9x rate when using \"edit_this\" group tokens.": "当特定用户分组使用特定令牌分组时的自定义乘数。示例:VIP 用户在使用“edit_this”分组令牌时获得 0.9 倍费率。", -+ "Custom navigation item": "自定义导航项", - "Custom OAuth": "自定义 OAuth", - "Custom OAuth Providers": "自定义OAuth提供商", - "Custom Seconds": "自定义秒数", -@@ -1333,6 +1337,7 @@ - "Designed and Developed by": "设计与开发", - "designed for scale": "为规模而设计", - "Designed for the continuous operation of AI models and Agents": "为 AI 模型与 Agent 的持续运营而设计", -+ "Destination path or URL.": "目标路径或 URL。", - "Destroyed": "已销毁", - "Detailed request logs for investigations.": "用于调查的详细请求日志。", - "Details": "详情", -@@ -1403,6 +1408,7 @@ - "Display Options": "显示选项", - "Display Token Statistics": "显示 Token 统计信息", - "Displayed in": "显示单位", -+ "Displayed label in the top navigation.": "顶部导航中显示的名称。", - "Displays the mobile sidebar.": "显示移动侧边栏。", - "Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.": "请勿过度信任此功能,IP 可能被伪造,请配合 nginx 和 cdn 等网关使用", - "Do not repeat check-in; only once per day": "请勿重复签到;每天仅一次", -@@ -1538,6 +1544,7 @@ - "Enable Discord OAuth": "启用 Discord OAuth", - "Enable Disk Cache": "启用磁盘缓存", - "Enable drawing features": "启用绘制功能", -+ "Enable Empty Completion Retry": "启用空补全重试", - "Enable filtering": "启用过滤", - "Enable FunctionCall thoughtSignature Fill": "启用 FunctionCall 思维签名填充", - "Enable GitHub OAuth": "启用 GitHub OAuth", -@@ -1807,6 +1814,7 @@ - "Failed to load home page content": "加载首页内容失败", - "Failed to load image": "无法加载图像", - "Failed to load key status": "加载密钥状态失败", -+ "Failed to load log details": "加载日志详情失败", - "Failed to load logs": "加载日志失败", - "Failed to load Passkey status": "加载 Passkey 状态失败", - "Failed to load playground groups": "加载 Playground 分组失败", -@@ -2561,6 +2569,7 @@ - "Maximum tokens including hidden reasoning tokens": "最大 token 数(含隐藏的推理 token)", - "Maximum tokens per response": "单次响应最大 token 数", - "maxRequests ≥ 0, maxSuccess ≥ 1, both ≤ 2,147,483,647": "maxRequests ≥ 0, maxSuccess ≥ 1,两者均 ≤ 2,147,483,647", -+ "May be caused by concurrent billing or manual adjustment.": "可能由并发扣费或手动调整导致。", - "May be used for training by upstream provider": "可能被上游提供商用于训练", - "Media pricing": "媒体定价", - "Median time-to-first-token (TTFT) sampled hourly per group": "按小时采样的各分组首 token 延迟(TTFT)中位数", -@@ -2762,8 +2771,12 @@ - "name@example.com": "name@example.com", - "Native format": "原生格式", - "Native forwarding": "原生转发", -+ "Navigation link": "导航链接", -+ "Navigation name": "导航名称", - "Need a redemption code?": "需要兑换码?", - "Needs API key": "需要 API 密钥", -+ "Negative": "负余额", -+ "Negative Balance": "负余额", - "Nested JSON defining per-group rules for adding (+:), removing (-:), or appending usable groups.": "嵌套 JSON,定义按分组添加(+:)、移除(-:)或追加可用分组的规则。", - "Nested JSON: source group →": "嵌套 JSON:源分组 →", - "Network proxy for this channel (supports socks5 protocol)": "此渠道的网络代理(支持 socks5 协议)", -@@ -3344,6 +3357,7 @@ - "Port": "端口", - "Port must be a positive integer": "端口必须为正整数", - "Position in the stack": "系统栈定位", -+ "Positive Balance": "正常余额", - "Post Delta": "结算差额", - "PostgreSQL detected": "检测到 PostgreSQL", - "PostgreSQL offers advanced reliability and data integrity for production workloads.": "PostgreSQL 为生产工作负载提供高级可靠性和数据完整性。", -@@ -3781,6 +3795,7 @@ - "Retention days": "保留天数", - "Retry": "重试", - "Retry Chain": "重试链路", -+ "Retry once when upstream returns an empty Chat or Responses completion before any stream data is sent. Requires Retry Times greater than 0.": "当上游在发送任何流数据前返回空的 Chat 或 Responses 补全时重试一次。需要将重试次数设置为大于 0。", - "Retry policy": "重试策略", - "Retry Suggestion": "重试建议", - "Retry Times": "重试次数", -@@ -4063,6 +4078,7 @@ - "Set API key access restrictions": "设置令牌的访问限制", - "Set API key basic information": "设置令牌的基本信息", - "Set Field": "设置字段", -+ "Set filters and click Search to load usage logs.": "设置筛选条件后点击搜索加载使用日志。", - "Set filters to customize your dashboard statistics and charts.": "设置筛选器以自定义您的仪表板统计数据和图表。", - "Set filters to narrow down your log search results.": "设置筛选器以缩小日志搜索结果范围。", - "Set Header": "设请求头", -@@ -4097,6 +4113,7 @@ - "Show": "显示", - "Show All": "显示全部", - "Show all providers including unbound": "显示所有提供商(包括未绑定)", -+ "Show one custom link in the top navigation.": "在顶部导航中显示一个自定义链接。", - "Show only bound providers": "仅显示已绑定的提供商", - "Show prices in currency instead of quota.": "以货币而非配额显示价格。", - "Show setup guide": "显示设置引导", -@@ -4603,6 +4620,7 @@ - "Total earned": "累计获得", - "Total Earned": "总收入", - "Total GPUs": "总 GPU", -+ "Total Granted": "总授予额度", - "Total invitation revenue": "总邀请收入", - "Total Log Size": "日志总大小", - "Total Quota": "总额度", -@@ -5087,6 +5105,7 @@ - "Your transaction history will appear here": "您的交易历史会显示在这里", - "Your Turnstile secret key": "您的 Turnstile 密钥", - "Your Turnstile site key": "您的 Turnstile 站点密钥", -+ "Zero Balance": "零余额", - "Zero retention": "零数据保留", - "Zhipu": "智谱", - "Zhipu V4": "智谱 V4", -diff --git a/web/default/src/lib/header-nav-modules.ts b/web/default/src/lib/header-nav-modules.ts -index bee15be..740769e 100644 ---- a/web/default/src/lib/header-nav-modules.ts -+++ b/web/default/src/lib/header-nav-modules.ts -@@ -21,6 +21,12 @@ export type HeaderNavAccessConfig = { - requireAuth: boolean - } - -+export type HeaderNavCustomConfig = { -+ enabled: boolean -+ title: string -+ href: string -+} -+ - export type HeaderNavModule = 'rankings' | 'pricing' - - export type HeaderNavModulesConfig = { -@@ -30,7 +36,8 @@ export type HeaderNavModulesConfig = { - rankings: HeaderNavAccessConfig - docs: boolean - about: boolean -- [key: string]: boolean | HeaderNavAccessConfig -+ custom: HeaderNavCustomConfig -+ [key: string]: boolean | HeaderNavAccessConfig | HeaderNavCustomConfig - } - - export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = { -@@ -46,6 +53,11 @@ export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = { - }, - docs: true, - about: true, -+ custom: { -+ enabled: false, -+ title: '', -+ href: '', -+ }, - } - - export const HEADER_NAV_MODULE_DEFAULTS: Record< -@@ -79,6 +91,7 @@ function cloneHeaderNavDefault(): HeaderNavModulesConfig { - ...HEADER_NAV_DEFAULT, - pricing: { ...HEADER_NAV_DEFAULT.pricing }, - rankings: { ...HEADER_NAV_DEFAULT.rankings }, -+ custom: { ...HEADER_NAV_DEFAULT.custom }, - } - } - -@@ -124,6 +137,23 @@ export function parseHeaderNavAccess( - return { ...fallback } - } - -+export function parseHeaderNavCustom( -+ raw: unknown, -+ fallback: HeaderNavCustomConfig -+): HeaderNavCustomConfig { -+ if (!raw || typeof raw !== 'object') { -+ return { ...fallback } -+ } -+ -+ const record = raw as Record -+ return { -+ enabled: parseHeaderNavBoolean(record.enabled, fallback.enabled), -+ title: -+ typeof record.title === 'string' ? record.title.trim() : fallback.title, -+ href: typeof record.href === 'string' ? record.href.trim() : fallback.href, -+ } -+} -+ - export function parseHeaderNavAccessOption( - raw: unknown, - fallback: HeaderNavAccessConfig -@@ -170,6 +200,10 @@ export function parseHeaderNavModules(raw: unknown): HeaderNavModulesConfig { - result.rankings = parseHeaderNavAccess(value, result.rankings) - return - } -+ if (key === 'custom') { -+ result.custom = parseHeaderNavCustom(value, result.custom) -+ return -+ } - - const fallback = result[key] - if ( -diff --git a/web/default/src/routes/_authenticated/usage-logs/$section.tsx b/web/default/src/routes/_authenticated/usage-logs/$section.tsx -index e75005d..b378934 100644 ---- a/web/default/src/routes/_authenticated/usage-logs/$section.tsx -+++ b/web/default/src/routes/_authenticated/usage-logs/$section.tsx -@@ -38,6 +38,7 @@ const logTypeSearchSchema = z - const usageLogsSearchSchema = z.object({ - page: z.number().optional().catch(1), - pageSize: z.number().optional().catch(undefined), -+ searchVersion: z.coerce.number().optional().catch(undefined), - type: logTypeSearchSchema.optional(), - filter: z.string().optional().catch(''), - model: z.string().optional().catch(''), -diff --git a/web/default/src/routes/_authenticated/users/index.tsx b/web/default/src/routes/_authenticated/users/index.tsx -index 2b9a29c..e4fed0b 100644 ---- a/web/default/src/routes/_authenticated/users/index.tsx -+++ b/web/default/src/routes/_authenticated/users/index.tsx -@@ -27,7 +27,7 @@ const usersSearchSchema = z.object({ - pageSize: z.number().optional().catch(undefined), - filter: z.string().optional().catch(''), - status: z -- .array(z.enum(['1', '2'])) -+ .array(z.enum(['-1', '1', '2'])) - .optional() - .catch([]), - role: z -@@ -35,6 +35,10 @@ const usersSearchSchema = z.object({ - .optional() - .catch([]), - group: z.string().optional().catch(''), -+ quota_status: z -+ .array(z.enum(['negative', 'zero', 'positive'])) -+ .optional() -+ .catch([]), - }) - - export const Route = createFileRoute('/_authenticated/users/')({ diff --git a/web/bun.lock b/web/bun.lock index ba2b366..2326d41 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -73,6 +73,7 @@ "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.18", "@trivago/prettier-plugin-sort-imports": "^6.0.2", + "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", @@ -82,6 +83,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "knip": "^6.14.2", "prettier": "catalog:", "prettier-plugin-tailwindcss": "^0.8.0", @@ -126,6 +128,14 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -192,8 +202,22 @@ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="], + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.0", "", {}, "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.1", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.9", "", { "dependencies": { "@csstools/color-helpers": "^6.1.0", "@csstools/css-calc": "^3.2.1" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.5", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -262,6 +286,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -846,6 +872,8 @@ "@types/js-cookie": ["@types/js-cookie@3.0.6", "", {}, "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ=="], + "@types/jsdom": ["@types/jsdom@28.0.3", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^8.0.0", "undici-types": "^7.21.0" } }, "sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], @@ -868,6 +896,8 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -994,6 +1024,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "binary-searching": ["binary-searching@2.0.5", "", {}, "sha512-v4N2l3RxL+m4zDxyxz3Ne2aTmiPn8ZUpKFpdPtO+ItW1NcTCXA7JeHG5GMBSvoKSkQZ9ycS+EouDVxYB9ufKWA=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -1096,6 +1128,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -1176,12 +1210,16 @@ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], "dayjs": ["dayjs@1.11.21", "", {}, "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -1244,7 +1282,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1486,6 +1524,8 @@ "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], @@ -1570,6 +1610,8 @@ "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], @@ -1602,6 +1644,8 @@ "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], @@ -1684,7 +1728,7 @@ "lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], @@ -1738,6 +1782,8 @@ "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -1922,7 +1968,7 @@ "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], - "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -2178,6 +2224,8 @@ "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], @@ -2290,6 +2338,8 @@ "swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="], + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], @@ -2328,6 +2378,8 @@ "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], @@ -2360,6 +2412,8 @@ "unbash": ["unbash@3.0.0", "", {}, "sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA=="], + "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -2430,14 +2484,22 @@ "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], @@ -2448,6 +2510,10 @@ "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], @@ -2474,6 +2540,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -2622,6 +2690,10 @@ "geojson-flatten/minimist": ["minimist@1.2.0", "", {}, "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw=="], + "hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], @@ -2768,6 +2840,10 @@ "express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + "hast-util-from-html/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "hast-util-raw/parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], "micromark-extension-math/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], diff --git a/web/default/package.json b/web/default/package.json index 67f9977..430b27e 100644 --- a/web/default/package.json +++ b/web/default/package.json @@ -82,6 +82,7 @@ "@tanstack/react-router-devtools": "^1.167.0", "@tanstack/router-plugin": "^1.168.18", "@trivago/prettier-plugin-sort-imports": "^6.0.2", + "@types/jsdom": "^28.0.3", "@types/node": "^25.9.1", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", @@ -91,6 +92,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", + "jsdom": "^29.1.1", "knip": "^6.14.2", "prettier": "catalog:", "prettier-plugin-tailwindcss": "^0.8.0", diff --git a/web/default/src/components/layout/components/app-header.tsx b/web/default/src/components/layout/components/app-header.tsx index 301de9f..75b8230 100644 --- a/web/default/src/components/layout/components/app-header.tsx +++ b/web/default/src/components/layout/components/app-header.tsx @@ -130,6 +130,7 @@ export function AppHeader({ void + onCloseForToday: () => void unreadCount: number activeTab: 'notice' | 'announcements' onTabChange: (tab: 'notice' | 'announcements') => void @@ -271,6 +272,7 @@ function AnnouncementsContent({ export function NotificationPopover({ open, onOpenChange, + onCloseForToday, unreadCount, activeTab, onTabChange, @@ -343,7 +345,10 @@ export function NotificationPopover({ -
+
+ diff --git a/web/default/src/components/ui/markdown.test.ts b/web/default/src/components/ui/markdown.test.ts new file mode 100644 index 0000000..a32080c --- /dev/null +++ b/web/default/src/components/ui/markdown.test.ts @@ -0,0 +1,92 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { JSDOM } from 'jsdom' +import { renderMarkdownForTest } from './markdown' + +const dom = new JSDOM('') +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: dom.window, +}) + +describe('renderMarkdownForTest', () => { + test('renders markdown links without losing the marked parser context', () => { + const html = renderMarkdownForTest('[MAX API](https://example.com)') + + assert.match(html, /MAX API<\/a>/) + }) + + test('falls back to link text for invalid href values', () => { + const badHref = `http://example.com/${String.fromCharCode(0xd800)}` + const html = renderMarkdownForTest(`[Broken](${badHref})`) + + assert.equal(html, '

Broken

\n') + }) + + test('sanitizes hostile html when rendered outside the browser', () => { + const html = renderMarkdownForTest('') + + assert.doesNotMatch(html, /onerror/i) + assert.doesNotMatch(html, /alert\(1\)/i) + }) + + test('removes unsafe link protocols when rendered outside the browser', () => { + const cases = [ + { + label: 'XSS', + markdown: '[XSS](javascript:alert(1))', + unsafe: /javascript:|alert\(1\)/i, + }, + { + label: 'Data', + markdown: '[Data](data:text/html,)', + unsafe: /data:text\/html| { + const html = renderMarkdownForTest(markdown) + + assert.doesNotMatch(html, unsafe) + assert.doesNotMatch(html, /href=/i) + assert.match(html, new RegExp(`>${label}<`)) + }) + }) + + test('fails closed when no DOM is available', () => { + const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + }) + + try { + assert.equal(renderMarkdownForTest(''), '') + } finally { + if (windowDescriptor) { + Object.defineProperty(globalThis, 'window', windowDescriptor) + } + } + }) +}) diff --git a/web/default/src/components/ui/markdown.tsx b/web/default/src/components/ui/markdown.tsx index e5c2f86..809a559 100644 --- a/web/default/src/components/ui/markdown.tsx +++ b/web/default/src/components/ui/markdown.tsx @@ -129,6 +129,13 @@ const sanitizeOptions = { ADD_TAGS: allowedTags, } as const +type DomPurifySanitizer = { + isSupported?: boolean + sanitize: (dirty: string, config?: typeof sanitizeOptions) => string +} + +type DomPurifyFactory = (window: Window) => DomPurifySanitizer + type FlowNode = { id: string label: string @@ -169,6 +176,56 @@ function escapeHtml(value: string): string { .replaceAll("'", ''') } +const allowedUrlProtocols = new Set(['http:', 'https:', 'mailto:', 'tel:']) +const urlProtocolPattern = /^[a-z][a-z\d+.-]*:/i + +function normalizeUrl(value: string): string | null { + try { + const normalized = encodeURI(value).replace(/%25/g, '%') + const protocol = urlProtocolPattern.exec(normalized.trimStart())?.[0].toLowerCase() + + if (protocol && !allowedUrlProtocols.has(protocol)) { + return null + } + + return normalized + } catch { + return null + } +} + +function isUsableSanitizer(value: unknown): value is DomPurifySanitizer { + if ((typeof value !== 'object' && typeof value !== 'function') || value === null) { + return false + } + + const sanitizer = value as Partial + return ( + sanitizer.isSupported !== false && + typeof sanitizer.sanitize === 'function' + ) +} + +function sanitizeHtml(html: string): string { + const purify = DOMPurify as unknown as + | DomPurifySanitizer + | DomPurifyFactory + + if (isUsableSanitizer(purify)) { + return purify.sanitize(html, sanitizeOptions) + } + + if (typeof window !== 'undefined' && typeof purify === 'function') { + const browserSanitizer = purify(window) + + if (isUsableSanitizer(browserSanitizer)) { + return browserSanitizer.sanitize(html, sanitizeOptions) + } + } + + return '' +} + function normalizeMathSource(source: string): string { return source .trim() @@ -566,7 +623,6 @@ function renderSequenceDiagram(source: string): string { function createMarkdownRenderer() { const renderer = new Renderer() const renderDefaultCode = renderer.code.bind(renderer) - const renderDefaultLink = renderer.link.bind(renderer) renderer.code = (token: Tokens.Code): string => { const language = token.lang?.toLowerCase() @@ -586,13 +642,17 @@ function createMarkdownRenderer() { return renderDefaultCode(token) } - renderer.link = (token: Tokens.Link): string => { - const html = renderDefaultLink(token) + renderer.link = function (this: Renderer, token: Tokens.Link): string { + const text = this.parser.parseInline(token.tokens) + const href = normalizeUrl(token.href) - return html.replace( - /^
${text}` } return renderer @@ -695,14 +755,17 @@ function createMarkdownParser() { return parser } -function renderMarkdown(markdown: string): string { +export function renderMarkdownForTest(markdown: string): string { const markdownParser = createMarkdownParser() const parsedHtml = markdownParser.parse(markdown, markdownOptions) - return DOMPurify.sanitize(parsedHtml, sanitizeOptions) + return sanitizeHtml(parsedHtml) } export function Markdown(props: MarkdownProps) { - const html = useMemo(() => renderMarkdown(props.children), [props.children]) + const html = useMemo( + () => renderMarkdownForTest(props.children), + [props.children] + ) return (
. + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' +import { + getAnnouncementKey, + getAutoNotificationTab, + getLastAutoOpenedNotificationSignature, + getNotificationContentSignature, + rememberAutoOpenedNotificationSignature, + shouldAutoOpenNotifications, +} from './notification-utils' + +describe('notification auto open helpers', () => { + test('treats edited announcements with the same id as new keys', () => { + const originalKey = getAnnouncementKey({ + id: 10, + content: 'Initial announcement', + publishDate: '2026-06-28T08:00:00Z', + }) + const editedKey = getAnnouncementKey({ + id: 10, + content: 'Edited announcement', + publishDate: '2026-06-28T08:00:00Z', + }) + + assert.notEqual(originalKey, editedKey) + assert.match(originalKey, /^id:10:hash:/) + assert.match(editedKey, /^id:10:hash:/) + }) + + test('keeps content signatures stable when announcement order changes', () => { + const firstSignature = getNotificationContentSignature('', [ + { id: 2, content: 'Second' }, + { id: 1, content: 'First' }, + ]) + const secondSignature = getNotificationContentSignature('', [ + { id: 1, content: 'First' }, + { id: 2, content: 'Second' }, + ]) + + assert.equal(firstSignature, secondSignature) + }) + + test('opens unread notice content after notifications finish loading', () => { + const unreadCounts = { + notice: 1, + announcements: 0, + total: 1, + } + + assert.equal( + getAutoNotificationTab({ + hasAnnouncements: false, + hasNotice: true, + unreadCounts, + }), + 'notice' + ) + assert.equal( + shouldAutoOpenNotifications({ + contentSignature: getNotificationContentSignature('new notice', []), + isClosedToday: false, + lastAutoOpenedSignature: null, + loading: false, + popoverOpen: false, + }), + true + ) + }) + + test('opens unread announcements when no notice is unread', () => { + const unreadCounts = { + notice: 0, + announcements: 1, + total: 1, + } + + assert.equal( + getAutoNotificationTab({ + hasAnnouncements: true, + hasNotice: false, + unreadCounts, + }), + 'announcements' + ) + assert.equal( + shouldAutoOpenNotifications({ + contentSignature: getNotificationContentSignature('', [ + { id: 10, content: 'new timeline item' }, + ]), + isClosedToday: false, + lastAutoOpenedSignature: null, + loading: false, + popoverOpen: false, + }), + true + ) + }) + + test('opens existing read notice content once per page session', () => { + const unreadCounts = { + notice: 0, + announcements: 0, + total: 0, + } + + assert.equal( + getAutoNotificationTab({ + hasAnnouncements: false, + hasNotice: true, + unreadCounts, + }), + 'notice' + ) + assert.equal( + shouldAutoOpenNotifications({ + contentSignature: getNotificationContentSignature('existing notice', []), + isClosedToday: false, + lastAutoOpenedSignature: null, + loading: false, + popoverOpen: false, + }), + true + ) + }) + + test('does not auto open after closing for today', () => { + assert.equal( + shouldAutoOpenNotifications({ + contentSignature: getNotificationContentSignature('new notice', []), + isClosedToday: true, + lastAutoOpenedSignature: null, + loading: false, + popoverOpen: false, + }), + false + ) + }) + + test('does not repeatedly auto open the same notification content', () => { + const contentSignature = getNotificationContentSignature('new notice', []) + + assert.equal( + shouldAutoOpenNotifications({ + contentSignature, + isClosedToday: false, + lastAutoOpenedSignature: contentSignature, + loading: false, + popoverOpen: false, + }), + false + ) + }) + + test('remembers auto opened content across hook remounts in the same page load', () => { + const contentSignature = getNotificationContentSignature('existing notice', []) + + assert.equal(rememberAutoOpenedNotificationSignature(contentSignature), true) + assert.equal(rememberAutoOpenedNotificationSignature(contentSignature), false) + }) + + test('does not auto open content already seen while the popover was open', () => { + const contentSignature = getNotificationContentSignature( + 'manually opened notice', + [] + ) + + assert.equal(rememberAutoOpenedNotificationSignature(contentSignature), true) + assert.equal( + shouldAutoOpenNotifications({ + contentSignature, + isClosedToday: false, + lastAutoOpenedSignature: getLastAutoOpenedNotificationSignature(), + loading: false, + popoverOpen: false, + }), + false + ) + }) +}) diff --git a/web/default/src/hooks/notification-utils.ts b/web/default/src/hooks/notification-utils.ts new file mode 100644 index 0000000..3b49002 --- /dev/null +++ b/web/default/src/hooks/notification-utils.ts @@ -0,0 +1,157 @@ +/* +Copyright (C) 2023-2026 MAX-API-Next + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues +*/ +export type NotificationTab = 'notice' | 'announcements' + +export interface NotificationUnreadCounts { + announcements: number + notice: number | string + total: number +} + +interface ShouldAutoOpenNotificationsOptions { + contentSignature: string + isClosedToday: boolean + lastAutoOpenedSignature: string | null + loading: boolean + popoverOpen: boolean +} + +interface GetAutoNotificationTabOptions { + hasAnnouncements: boolean + hasNotice: boolean + unreadCounts: NotificationUnreadCounts +} + +let memoryLastAutoOpenedSignature: string | null = null + +function hashString(input: string): string { + let hash = 0 + if (!input) return '0' + + for (let i = 0; i < input.length; i += 1) { + const chr = input.charCodeAt(i) + hash = (hash << 5) - hash + chr + hash |= 0 + } + + return hash.toString(36) +} + +function getAnnouncementFingerprint(item: Record): string { + return JSON.stringify({ + publishDate: (item?.publishDate as string) || '', + content: ((item?.content as string) || '').trim(), + extra: ((item?.extra as string) || '').trim(), + type: (item?.type as string) || '', + title: ((item?.title as string) || '').trim(), + link: ((item?.link as string) || '').trim(), + }) +} + +/** + * Generate a unique key for an announcement. + * Prefer backend id but include a content version so edits register. + */ +export function getAnnouncementKey(item: Record): string { + if (!item) return '' + + const version = hashString(getAnnouncementFingerprint(item)) + + if (item.id !== undefined && item.id !== null) { + return `id:${item.id}:hash:${version}` + } + + return `hash:${version}` +} + +export function getNotificationContentSignature( + noticeContent: string, + announcements: Record[] +): string { + const notice = noticeContent.trim() + const announcementKeys = announcements + .map((item: Record) => getAnnouncementKey(item)) + .filter(Boolean) + .sort() + + if (!notice && announcementKeys.length === 0) { + return '' + } + + return JSON.stringify({ + notice, + announcements: announcementKeys, + }) +} + +export function getAutoNotificationTab( + options: GetAutoNotificationTabOptions +): NotificationTab | null { + if (Number(options.unreadCounts.notice) > 0) { + return 'notice' + } + + if (options.unreadCounts.announcements > 0) { + return 'announcements' + } + + if (options.hasNotice) { + return 'notice' + } + + if (options.hasAnnouncements) { + return 'announcements' + } + + return null +} + +export function shouldAutoOpenNotifications( + options: ShouldAutoOpenNotificationsOptions +): boolean { + return ( + !options.loading && + !options.popoverOpen && + !options.isClosedToday && + options.contentSignature !== '' && + options.contentSignature !== options.lastAutoOpenedSignature + ) +} + +export function getLastAutoOpenedNotificationSignature(): string | null { + return memoryLastAutoOpenedSignature +} + +export function rememberAutoOpenedNotificationSignature( + contentSignature: string +): boolean { + if (!contentSignature) { + return false + } + + const lastSignature = getLastAutoOpenedNotificationSignature() + + if (lastSignature === contentSignature) { + return false + } + + memoryLastAutoOpenedSignature = contentSignature + + return true +} diff --git a/web/default/src/hooks/use-notifications.ts b/web/default/src/hooks/use-notifications.ts index 0679819..c418b3f 100644 --- a/web/default/src/hooks/use-notifications.ts +++ b/web/default/src/hooks/use-notifications.ts @@ -16,56 +16,49 @@ along with this program. If not, see . For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues */ -import { useState, useMemo } from 'react' -import { useQuery } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useQuery, type UseQueryResult } from '@tanstack/react-query' import { useNotificationStore } from '@/stores/notification-store' import { getNotice } from '@/lib/api' import { useStatus } from '@/hooks/use-status' +import { + getAnnouncementKey, + getAutoNotificationTab, + getNotificationContentSignature, + getLastAutoOpenedNotificationSignature, + rememberAutoOpenedNotificationSignature, + shouldAutoOpenNotifications, + type NotificationTab, +} from './notification-utils' -function hashString(input: string): string { - let hash = 0 - if (!input) return '0' +type RefetchNotice = UseQueryResult< + Awaited> +>['refetch'] - for (let i = 0; i < input.length; i += 1) { - const chr = input.charCodeAt(i) - hash = (hash << 5) - hash + chr - hash |= 0 - } - - return hash.toString(36) -} - -/** - * Generate a unique key for an announcement - * Prefer backend id, fall back to a content hash so edits register - */ -function getAnnouncementKey(item: Record): string { - if (!item) return '' - - if (item.id !== undefined && item.id !== null) { - return `id:${item.id}` - } - - const fingerprint = JSON.stringify({ - publishDate: (item?.publishDate as string) || '', - content: ((item?.content as string) || '').trim(), - extra: ((item?.extra as string) || '').trim(), - type: (item?.type as string) || '', - title: ((item?.title as string) || '').trim(), - link: ((item?.link as string) || '').trim(), - }) - return `hash:${hashString(fingerprint)}` +export type UseNotificationsResult = { + activeTab: NotificationTab + announcements: Record[] + closeForToday: () => void + closePopover: () => void + loading: boolean + notice: string + openPopover: (tab?: NotificationTab) => void + popoverOpen: boolean + refetchNotice: RefetchNotice + setActiveTab: (tab: NotificationTab) => void + setPopoverOpen: (open: boolean) => void + unreadAnnouncementsCount: number + unreadCount: number + unreadNoticeCount: number } /** * Hook to manage notifications (Notice + Announcements) * Provides unread counts and read status management */ -export function useNotifications() { +export function useNotifications(): UseNotificationsResult { const [popoverOpen, setPopoverOpen] = useState(false) - const [activeTab, setActiveTab] = useState<'notice' | 'announcements'>( - 'notice' - ) + const [activeTab, setActiveTab] = useState('notice') // Fetch Notice from API const { @@ -81,17 +74,21 @@ export function useNotifications() { // Fetch Announcements from status const { status, loading: statusLoading } = useStatus() const announcementsEnabled = status?.announcements_enabled ?? false - // eslint-disable-next-line react-hooks/exhaustive-deps - const announcements: Record[] = announcementsEnabled - ? ((status?.announcements || []) as Record[]).slice(0, 20) - : [] + const statusAnnouncements = status?.announcements + const announcements: Record[] = useMemo(() => { + return announcementsEnabled + ? ((statusAnnouncements || []) as Record[]).slice(0, 20) + : [] + }, [announcementsEnabled, statusAnnouncements]) // Notification store const { lastReadNotice, markNoticeRead, markAnnouncementsRead, + setClosedUntilDate, isAnnouncementRead, + isNoticeClosed, } = useNotificationStore() // Extract notice content @@ -118,54 +115,127 @@ export function useNotifications() { } }, [noticeContent, lastReadNotice, announcements, isAnnouncementRead]) - const markAnnouncementsAsRead = () => { + const loading = noticeLoading || statusLoading + const contentSignature = useMemo( + () => getNotificationContentSignature(noticeContent, announcements), + [noticeContent, announcements] + ) + + const markAnnouncementsAsRead = useCallback(() => { if (announcements.length > 0) { const allKeys = announcements.map((item: Record) => getAnnouncementKey(item) ) markAnnouncementsRead(allKeys) } - } + }, [announcements, markAnnouncementsRead]) + + const markTabAsRead = useCallback( + (tab: NotificationTab) => { + if (tab === 'notice' && noticeContent) { + markNoticeRead(noticeContent) + } + + if (tab === 'announcements') { + markAnnouncementsAsRead() + } + }, + [markAnnouncementsAsRead, markNoticeRead, noticeContent] + ) // Handle popover open - const handleOpenPopover = (tab?: 'notice' | 'announcements') => { - const nextTab = tab || activeTab + const handleOpenPopover = useCallback( + (tab?: NotificationTab) => { + const nextTab = tab || activeTab + + markTabAsRead(nextTab) + setActiveTab(nextTab) + setPopoverOpen(true) + }, + [activeTab, markTabAsRead] + ) + + const handlePopoverOpenChange = useCallback( + (open: boolean) => { + if (open) { + handleOpenPopover(activeTab) + return + } + + setPopoverOpen(false) + }, + [activeTab, handleOpenPopover] + ) + + const closeForToday = useCallback(() => { + setClosedUntilDate(new Date().toDateString()) + setPopoverOpen(false) + }, [setClosedUntilDate]) + + // Handle tab change - mark announcements as read when switching to that tab + const handleTabChange = useCallback( + (tab: NotificationTab) => { + setActiveTab(tab) + markTabAsRead(tab) + }, + [markTabAsRead] + ) - // Mark currently visible content as read when opening the notification center - if (noticeContent) { - markNoticeRead(noticeContent) + useEffect(() => { + if (popoverOpen && contentSignature !== '') { + rememberAutoOpenedNotificationSignature(contentSignature) + return } - if (nextTab === 'announcements') { - markAnnouncementsAsRead() + + if ( + !shouldAutoOpenNotifications({ + contentSignature, + isClosedToday: isNoticeClosed(), + lastAutoOpenedSignature: getLastAutoOpenedNotificationSignature(), + loading, + popoverOpen, + }) + ) { + return } - setActiveTab(nextTab) - setPopoverOpen(true) - } + const autoTab = getAutoNotificationTab({ + hasAnnouncements: announcements.length > 0, + hasNotice: noticeContent !== '', + unreadCounts, + }) - const handlePopoverOpenChange = (open: boolean) => { - if (open) { - handleOpenPopover(activeTab) + if (!autoTab) { return } - setPopoverOpen(false) - } + const timeoutId = window.setTimeout(() => { + if (!rememberAutoOpenedNotificationSignature(contentSignature)) { + return + } - // Handle tab change - mark announcements as read when switching to that tab - const handleTabChange = (tab: 'notice' | 'announcements') => { - setActiveTab(tab) + handleOpenPopover(autoTab) + }, 0) - if (tab === 'announcements') { - markAnnouncementsAsRead() + return () => { + window.clearTimeout(timeoutId) } - } + }, [ + contentSignature, + announcements.length, + handleOpenPopover, + isNoticeClosed, + loading, + noticeContent, + popoverOpen, + unreadCounts, + ]) return { // Data notice: noticeContent, announcements, - loading: noticeLoading || statusLoading, + loading, // Unread counts unreadCount: unreadCounts.total, @@ -181,6 +251,7 @@ export function useNotifications() { // Actions openPopover: handleOpenPopover, closePopover: () => setPopoverOpen(false), + closeForToday, refetchNotice, } }