fix: batch 1 audit fixes — quota SQL fixed mode, public recharge URL, WebSearch bool fallback, UpdatePlan validation

H1: incrementUsageBillingAccountQuota now uses shared dailyExpiredExpr/weeklyExpiredExpr
    constants (supporting fixed reset mode) instead of hardcoded '24 hours'/'168 hours'
H4: public settings endpoint now maps balance_low_notify_recharge_url
H6: GetWebSearchEmulationMode tolerates legacy bool values (true→enabled)
H7: UpdatePlan validates non-nil patch fields (rejects negative price, empty name, etc.)
H8: UsageTable accountBilled() helper with total_cost ?? 0 null guard
H9: AdminUsageLog TS type adds channel_id + billing_tier
M2: account.go "fixed" literals replaced with thresholdTypeFixed constant
M13: SystemSettings TS type adds web_search_emulation_enabled
UI: QuotaLimitCard title labels now use flex-1 to align with flex-1 input boxes
This commit is contained in:
erio
2026-04-13 21:38:51 +08:00
parent ca673f9899
commit ed8a9d975b
10 changed files with 104 additions and 27 deletions

View File

@@ -1 +1 @@
0.1.110.44
0.1.110.47

View File

@@ -64,5 +64,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
})
}

View File

@@ -248,30 +248,32 @@ func incrementUsageBillingAccountQuota(ctx context.Context, tx *sql.Tx, accountI
|| CASE WHEN COALESCE((extra->>'quota_daily_limit')::numeric, 0) > 0 THEN
jsonb_build_object(
'quota_daily_used',
CASE WHEN COALESCE((extra->>'quota_daily_start')::timestamptz, '1970-01-01'::timestamptz)
+ '24 hours'::interval <= NOW()
CASE WHEN `+dailyExpiredExpr+`
THEN $1
ELSE COALESCE((extra->>'quota_daily_used')::numeric, 0) + $1 END,
'quota_daily_start',
CASE WHEN COALESCE((extra->>'quota_daily_start')::timestamptz, '1970-01-01'::timestamptz)
+ '24 hours'::interval <= NOW()
CASE WHEN `+dailyExpiredExpr+`
THEN `+nowUTC+`
ELSE COALESCE(extra->>'quota_daily_start', `+nowUTC+`) END
)
|| CASE WHEN `+dailyExpiredExpr+` AND `+nextDailyResetAtExpr+` IS NOT NULL
THEN jsonb_build_object('quota_daily_reset_at', `+nextDailyResetAtExpr+`)
ELSE '{}'::jsonb END
ELSE '{}'::jsonb END
|| CASE WHEN COALESCE((extra->>'quota_weekly_limit')::numeric, 0) > 0 THEN
jsonb_build_object(
'quota_weekly_used',
CASE WHEN COALESCE((extra->>'quota_weekly_start')::timestamptz, '1970-01-01'::timestamptz)
+ '168 hours'::interval <= NOW()
CASE WHEN `+weeklyExpiredExpr+`
THEN $1
ELSE COALESCE((extra->>'quota_weekly_used')::numeric, 0) + $1 END,
'quota_weekly_start',
CASE WHEN COALESCE((extra->>'quota_weekly_start')::timestamptz, '1970-01-01'::timestamptz)
+ '168 hours'::interval <= NOW()
CASE WHEN `+weeklyExpiredExpr+`
THEN `+nowUTC+`
ELSE COALESCE(extra->>'quota_weekly_start', `+nowUTC+`) END
)
|| CASE WHEN `+weeklyExpiredExpr+` AND `+nextWeeklyResetAtExpr+` IS NOT NULL
THEN jsonb_build_object('quota_weekly_reset_at', `+nextWeeklyResetAtExpr+`)
ELSE '{}'::jsonb END
ELSE '{}'::jsonb END
), updated_at = NOW()
WHERE id = $2 AND deleted_at IS NULL

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"hash/fnv"
"log/slog"
"reflect"
"sort"
"strconv"
@@ -1178,12 +1179,21 @@ const (
// GetWebSearchEmulationMode 返回账号的 WebSearch 模拟模式。
// 三态default跟随渠道/ enabled强制开启/ disabled强制关闭
// 旧 bool 值需通过 SQL 迁移脚本转换Go 代码不做兼容
// 兼容旧 bool 值true→enabled, false→default并记录 debug 日志)
func (a *Account) GetWebSearchEmulationMode() string {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
return WebSearchModeDefault
}
mode, ok := a.Extra[featureKeyWebSearchEmulation].(string)
raw := a.Extra[featureKeyWebSearchEmulation]
// Tolerant: legacy bool values (pre-migration or stale writes)
if b, ok := raw.(bool); ok {
slog.Debug("legacy bool web_search_emulation value", "account_id", a.ID, "value", b)
if b {
return WebSearchModeEnabled
}
return WebSearchModeDefault
}
mode, ok := raw.(string)
if !ok {
return WebSearchModeDefault
}
@@ -1522,7 +1532,7 @@ func (a *Account) GetQuotaNotifyDailyThreshold() float64 {
}
func (a *Account) GetQuotaNotifyDailyThresholdType() string {
return a.getExtraStringDefault("quota_notify_daily_threshold_type", "fixed")
return a.getExtraStringDefault("quota_notify_daily_threshold_type", thresholdTypeFixed)
}
func (a *Account) GetQuotaNotifyWeeklyEnabled() bool {
@@ -1534,7 +1544,7 @@ func (a *Account) GetQuotaNotifyWeeklyThreshold() float64 {
}
func (a *Account) GetQuotaNotifyWeeklyThresholdType() string {
return a.getExtraStringDefault("quota_notify_weekly_threshold_type", "fixed")
return a.getExtraStringDefault("quota_notify_weekly_threshold_type", thresholdTypeFixed)
}
func (a *Account) GetQuotaNotifyTotalEnabled() bool {
@@ -1546,7 +1556,7 @@ func (a *Account) GetQuotaNotifyTotalThreshold() float64 {
}
func (a *Account) GetQuotaNotifyTotalThresholdType() string {
return a.getExtraStringDefault("quota_notify_total_threshold_type", "fixed")
return a.getExtraStringDefault("quota_notify_total_threshold_type", thresholdTypeFixed)
}
// nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点

View File

@@ -50,8 +50,8 @@ func TestGetWebSearchEmulationMode_OldBoolTrue(t *testing.T) {
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: true},
}
// bool is not a string, type assertion fails → default
require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode())
// bool true → tolerant fallback → enabled (not default)
require.Equal(t, WebSearchModeEnabled, a.GetWebSearchEmulationMode())
}
func TestGetWebSearchEmulationMode_OldBoolFalse(t *testing.T) {

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"strings"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/group"
@@ -10,6 +11,46 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
)
// validatePlanRequired checks that all required fields for a plan are provided.
func validatePlanRequired(name string, groupID int64, price float64, validityDays int, validityUnit string) error {
if strings.TrimSpace(name) == "" {
return infraerrors.BadRequest("PLAN_NAME_REQUIRED", "plan name is required")
}
if groupID <= 0 {
return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "group is required")
}
if price <= 0 {
return infraerrors.BadRequest("PLAN_PRICE_INVALID", "price must be > 0")
}
if validityDays <= 0 {
return infraerrors.BadRequest("PLAN_VALIDITY_REQUIRED", "validity days must be > 0")
}
if strings.TrimSpace(validityUnit) == "" {
return infraerrors.BadRequest("PLAN_VALIDITY_UNIT_REQUIRED", "validity unit is required")
}
return nil
}
// validatePlanPatch validates only the non-nil fields in a patch update.
func validatePlanPatch(req UpdatePlanRequest) error {
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
return infraerrors.BadRequest("PLAN_NAME_REQUIRED", "plan name is required")
}
if req.GroupID != nil && *req.GroupID <= 0 {
return infraerrors.BadRequest("PLAN_GROUP_REQUIRED", "group is required")
}
if req.Price != nil && *req.Price <= 0 {
return infraerrors.BadRequest("PLAN_PRICE_INVALID", "price must be > 0")
}
if req.ValidityDays != nil && *req.ValidityDays <= 0 {
return infraerrors.BadRequest("PLAN_VALIDITY_REQUIRED", "validity days must be > 0")
}
if req.ValidityUnit != nil && strings.TrimSpace(*req.ValidityUnit) == "" {
return infraerrors.BadRequest("PLAN_VALIDITY_UNIT_REQUIRED", "validity unit is required")
}
return nil
}
// --- Plan CRUD ---
// PlanGroupInfo holds the group details needed for subscription plan display.
@@ -66,14 +107,17 @@ func (s *PaymentConfigService) GetGroupInfoMap(ctx context.Context, plans []*dbe
}
func (s *PaymentConfigService) ListPlans(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.BySortOrder()).All(ctx)
return s.entClient.SubscriptionPlan.Query().Order(subscriptionplan.ByCreatedAt()).All(ctx)
}
func (s *PaymentConfigService) ListPlansForSale(ctx context.Context) ([]*dbent.SubscriptionPlan, error) {
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.BySortOrder()).All(ctx)
return s.entClient.SubscriptionPlan.Query().Where(subscriptionplan.ForSaleEQ(true)).Order(subscriptionplan.ByCreatedAt()).All(ctx)
}
func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanRequest) (*dbent.SubscriptionPlan, error) {
if err := validatePlanRequired(req.Name, req.GroupID, req.Price, req.ValidityDays, req.ValidityUnit); err != nil {
return nil, err
}
b := s.entClient.SubscriptionPlan.Create().
SetGroupID(req.GroupID).SetName(req.Name).SetDescription(req.Description).
SetPrice(req.Price).SetValidityDays(req.ValidityDays).SetValidityUnit(req.ValidityUnit).
@@ -86,8 +130,12 @@ func (s *PaymentConfigService) CreatePlan(ctx context.Context, req CreatePlanReq
}
// UpdatePlan updates a subscription plan by ID (patch semantics).
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update boilerplate.
// NOTE: This function exceeds 30 lines due to per-field nil-check patch update boilerplate
// plus a validation guard for non-nil fields.
func (s *PaymentConfigService) UpdatePlan(ctx context.Context, id int64, req UpdatePlanRequest) (*dbent.SubscriptionPlan, error) {
if err := validatePlanPatch(req); err != nil {
return nil, err
}
u := s.entClient.SubscriptionPlan.UpdateOneID(id)
if req.GroupID != nil {
u.SetGroupID(*req.GroupID)

View File

@@ -114,6 +114,7 @@ export interface SystemSettings {
enable_fingerprint_unification: boolean
enable_metadata_passthrough: boolean
enable_cch_signing: boolean
web_search_emulation_enabled?: boolean
// Payment configuration
payment_enabled: boolean

View File

@@ -201,8 +201,8 @@ const onWeeklyModeChange = (e: Event) => {
<div>
<!-- 标题行仅全局通知开启时显示 -->
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 w-28 flex-shrink-0">{{ t('admin.accounts.quotaDailyLimit') }}</span>
<span v-if="dailyLimit && dailyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaDailyLimit') }}</span>
<span v-if="dailyLimit && dailyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaNotify.alert') }}</span>
</div>
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaDailyLimit') }}</label>
<!-- 输入行 -->
@@ -240,8 +240,8 @@ const onWeeklyModeChange = (e: Event) => {
<!-- 周配额 -->
<div>
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 w-28 flex-shrink-0">{{ t('admin.accounts.quotaWeeklyLimit') }}</span>
<span v-if="weeklyLimit && weeklyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaWeeklyLimit') }}</span>
<span v-if="weeklyLimit && weeklyLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaNotify.alert') }}</span>
</div>
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaWeeklyLimit') }}</label>
<div class="flex items-center gap-2">
@@ -290,8 +290,8 @@ const onWeeklyModeChange = (e: Event) => {
<!-- 总配额 -->
<div>
<div v-if="quotaNotifyGlobalEnabled" class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 w-28 flex-shrink-0">{{ t('admin.accounts.quotaTotalLimit') }}</span>
<span v-if="totalLimit && totalLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.quotaNotify.alert') }}</span>
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaTotalLimit') }}</span>
<span v-if="totalLimit && totalLimit > 0" class="text-xs font-medium text-gray-700 dark:text-gray-300 flex-1 min-w-0">{{ t('admin.accounts.quotaNotify.alert') }}</span>
</div>
<label v-else class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1 block">{{ t('admin.accounts.quotaTotalLimit') }}</label>
<div class="flex items-center gap-2">

View File

@@ -155,7 +155,7 @@
</div>
</div>
<div v-if="row.account_rate_multiplier != null" class="mt-0.5 text-[11px] text-gray-400">
A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }}
A ${{ accountBilled(row).toFixed(6) }}
</div>
</div>
</template>
@@ -328,7 +328,11 @@
<div class="flex items-center justify-between gap-6 border-t border-gray-700 pt-1.5">
<span class="text-gray-400">{{ t('usage.accountBilled') }}</span>
<span class="font-semibold text-green-400">
${{ (((tooltipData?.total_cost || 0) * (tooltipData?.account_rate_multiplier ?? 1)) || 0).toFixed(6) }}
${{ accountBilled({
total_cost: tooltipData?.total_cost,
account_stats_cost: tooltipData?.account_stats_cost,
account_rate_multiplier: tooltipData?.account_rate_multiplier,
}).toFixed(6) }}
</span>
</div>
</div>
@@ -347,6 +351,13 @@ import { formatTokenPricePerMillion } from '@/utils/usagePricing'
import { getUsageServiceTierLabel } from '@/utils/usageServiceTier'
import { resolveUsageRequestType } from '@/utils/usageRequestType'
import { getBillingModeLabel, getBillingModeBadgeClass } from '@/utils/billingMode'
/** Compute the account-billed cost for display: (account_stats_cost ?? total_cost) * rate_multiplier */
function accountBilled(row: { total_cost?: number | null; account_stats_cost?: number | null; account_rate_multiplier?: number | null }): number {
const base = row.account_stats_cost != null ? row.account_stats_cost : (row.total_cost ?? 0)
return base * (row.account_rate_multiplier ?? 1)
}
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'

View File

@@ -1067,6 +1067,10 @@ export interface AdminUsageLog extends UsageLog {
// 账号计费倍率(仅管理员可见)
account_rate_multiplier?: number | null
// 渠道 ID 和计费等级(仅管理员可见)
channel_id?: number | null
billing_tier?: string | null
// 用户请求 IP仅管理员可见
ip_address?: string | null