diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 76129f5c..0ac4459d 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.110.44 +0.1.110.47 diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 69ffd287..1717b7a1 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -64,5 +64,6 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled, AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, + BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, }) } diff --git a/backend/internal/repository/usage_billing_repo.go b/backend/internal/repository/usage_billing_repo.go index b4c76da5..cd54baa3 100644 --- a/backend/internal/repository/usage_billing_repo.go +++ b/backend/internal/repository/usage_billing_repo.go @@ -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 diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 4d933986..4a4c0889 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 之后的下一个每日固定重置时间点 diff --git a/backend/internal/service/account_websearch_test.go b/backend/internal/service/account_websearch_test.go index b4d23c6b..6ed69d4c 100644 --- a/backend/internal/service/account_websearch_test.go +++ b/backend/internal/service/account_websearch_test.go @@ -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) { diff --git a/backend/internal/service/payment_config_plans.go b/backend/internal/service/payment_config_plans.go index 8a3e2950..8a5e1924 100644 --- a/backend/internal/service/payment_config_plans.go +++ b/backend/internal/service/payment_config_plans.go @@ -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) diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 76c94cd8..4b5eb242 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -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 diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue index ff5c11f5..5f0c7c2c 100644 --- a/frontend/src/components/account/QuotaLimitCard.vue +++ b/frontend/src/components/account/QuotaLimitCard.vue @@ -201,8 +201,8 @@ const onWeeklyModeChange = (e: Event) => {
- {{ t('admin.accounts.quotaDailyLimit') }} - {{ t('admin.accounts.quotaNotify.alert') }} + {{ t('admin.accounts.quotaDailyLimit') }} + {{ t('admin.accounts.quotaNotify.alert') }}
@@ -240,8 +240,8 @@ const onWeeklyModeChange = (e: Event) => {
- {{ t('admin.accounts.quotaWeeklyLimit') }} - {{ t('admin.accounts.quotaNotify.alert') }} + {{ t('admin.accounts.quotaWeeklyLimit') }} + {{ t('admin.accounts.quotaNotify.alert') }}
@@ -290,8 +290,8 @@ const onWeeklyModeChange = (e: Event) => {
- {{ t('admin.accounts.quotaTotalLimit') }} - {{ t('admin.accounts.quotaNotify.alert') }} + {{ t('admin.accounts.quotaTotalLimit') }} + {{ t('admin.accounts.quotaNotify.alert') }}
diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index c405e66b..441e89e0 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -155,7 +155,7 @@
- A ${{ (row.total_cost * row.account_rate_multiplier).toFixed(6) }} + A ${{ accountBilled(row).toFixed(6) }}
@@ -328,7 +328,11 @@
{{ t('usage.accountBilled') }} - ${{ (((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) }}
@@ -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' diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 8d30b642..ebb58a21 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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