From f3f8d08b56355543c8bd2c649da1994d9ff73807 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Thu, 26 Mar 2026 23:25:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.14.1=20=E8=A7=86=E9=A2=91=E5=8F=82?= =?UTF-8?q?=E8=80=83=E5=8F=8C=E5=8D=95=E4=BB=B7=20+=20Token=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E9=98=B2=E6=8A=96=20+=20CSV=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 计费双单价:含视频输入28元/百万tokens,不含视频输入46元/百万tokens - QuotaConfig 加 base_token_price_video 字段,系统设置页两个并排输入框 - 预估费用和实际结算按参考素材类型自动选择单价 - Token 刷新加锁:同页面内并发 401 共用一次 refresh 请求 - 关闭 BLACKLIST_AFTER_ROTATION:防止快速刷新导致误登出 - ProtectedRoute 容错:请求中断时自动重试,不误跳转 - CSV 导出上限从 100 提升到 10000 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0013_add_video_token_price.py | 23 +++++++++ backend/apps/generation/models.py | 3 +- backend/apps/generation/serializers.py | 1 + backend/apps/generation/views.py | 24 ++++++++-- backend/config/settings.py | 2 +- web/src/components/ProtectedRoute.tsx | 30 +++++++++++- web/src/lib/api.ts | 48 ++++++++++++------- web/src/pages/AuditLogsPage.tsx | 3 +- web/src/pages/SettingsPage.tsx | 28 +++++++---- web/src/store/auth.ts | 45 +++++++++-------- web/src/types/index.ts | 1 + 11 files changed, 154 insertions(+), 54 deletions(-) create mode 100644 backend/apps/generation/migrations/0013_add_video_token_price.py diff --git a/backend/apps/generation/migrations/0013_add_video_token_price.py b/backend/apps/generation/migrations/0013_add_video_token_price.py new file mode 100644 index 0000000..75baf42 --- /dev/null +++ b/backend/apps/generation/migrations/0013_add_video_token_price.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.29 on 2026-03-26 13:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('generation', '0012_add_raw_error'), + ] + + operations = [ + migrations.AddField( + model_name='quotaconfig', + name='base_token_price_video', + field=models.DecimalField(decimal_places=2, default=28, max_digits=10, verbose_name='基础token单价-含视频(元/百万tokens)'), + ), + migrations.AlterField( + model_name='quotaconfig', + name='base_token_price', + field=models.DecimalField(decimal_places=2, default=46, max_digits=10, verbose_name='基础token单价-不含视频(元/百万tokens)'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index 7ddca37..a3e33cc 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -90,7 +90,8 @@ class QuotaConfig(models.Model): # ── 计费全局配置(v0.10.0 新增) ── default_daily_generation_limit = models.IntegerField(default=50, verbose_name='默认每日生成次数') default_monthly_generation_limit = models.IntegerField(default=1500, verbose_name='默认每月生成次数') - base_token_price = models.DecimalField(max_digits=10, decimal_places=2, default=46, verbose_name='基础token单价(元/百万tokens)') + base_token_price = models.DecimalField(max_digits=10, decimal_places=2, default=46, verbose_name='基础token单价-不含视频(元/百万tokens)') + base_token_price_video = models.DecimalField(max_digits=10, decimal_places=2, default=28, verbose_name='基础token单价-含视频(元/百万tokens)') updated_at = models.DateTimeField(auto_now=True) class Meta: diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index 8d27a8d..502f584 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -37,6 +37,7 @@ class SystemSettingsSerializer(serializers.Serializer): default_daily_generation_limit = serializers.IntegerField(min_value=0, required=False) default_monthly_generation_limit = serializers.IntegerField(min_value=0, required=False) base_token_price = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False) + base_token_price_video = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0, required=False) announcement = serializers.CharField(required=False, allow_blank=True, default='') announcement_enabled = serializers.BooleanField(required=False, default=False) max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1) diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 51d2b16..da62652 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -48,6 +48,13 @@ def _safe_int(value, default=0): return default +def _has_video_reference(references): + """判断参考素材里是否包含视频类型,用于选择单价。""" + if not references: + return False + return any(ref.get('type') == 'video' for ref in references) + + # Columns added in migration 0003; may not exist in production DB yet. _M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls') _m0003_ok = None # None = unknown, True = columns exist, False = missing @@ -168,7 +175,9 @@ def video_generate_view(request): config = QuotaConfig.objects.get_or_create(pk=1)[0] w, h = get_resolution(aspect_ratio) estimated_tokens = estimate_tokens(w, h, duration) - estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage) + has_video_ref = _has_video_reference(request.data.get('references', [])) + token_price = config.base_token_price_video if has_video_ref else config.base_token_price + estimated_cost = calculate_cost(estimated_tokens, token_price, team.markup_percentage) # ── 所有额度检查在 transaction 内完成,select_for_update 串行化同团队请求 ── with transaction.atomic(): @@ -427,8 +436,10 @@ def _settle_payment(record, total_tokens): if not team: return config = QuotaConfig.objects.get_or_create(pk=1)[0] - actual_cost = calculate_cost(total_tokens, config.base_token_price, team.markup_percentage) - base_cost = calculate_base_cost(total_tokens, config.base_token_price) + has_video_ref = _has_video_reference(record.reference_urls) + token_price = config.base_token_price_video if has_video_ref else config.base_token_price + actual_cost = calculate_cost(total_tokens, token_price, team.markup_percentage) + base_cost = calculate_base_cost(total_tokens, token_price) frozen = record.frozen_amount with transaction.atomic(): @@ -1592,7 +1603,8 @@ def admin_create_user_view(request): def admin_records_view(request): """GET /api/v1/admin/records""" page = _safe_int(request.query_params.get('page', 1), 1) - page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100) + # CSV export may request up to 10000 records + page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 10000) search = request.query_params.get('search', '').strip() start_date = request.query_params.get('start_date', '').strip() end_date = request.query_params.get('end_date', '').strip() @@ -1657,7 +1669,8 @@ def team_records_view(request): """GET /api/v1/team/records — 团管查看本团队消费记录""" team = request.user.team page = _safe_int(request.query_params.get('page', 1), 1) - page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100) + # CSV export may request up to 10000 records + page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 10000) search = request.query_params.get('search', '').strip() start_date = request.query_params.get('start_date', '').strip() end_date = request.query_params.get('end_date', '').strip() @@ -1721,6 +1734,7 @@ def _settings_dict(config): 'default_daily_generation_limit': config.default_daily_generation_limit, 'default_monthly_generation_limit': config.default_monthly_generation_limit, 'base_token_price': float(config.base_token_price), + 'base_token_price_video': float(config.base_token_price_video), 'announcement': config.announcement, 'announcement_enabled': config.announcement_enabled, 'max_desktop_sessions': config.max_desktop_sessions, diff --git a/backend/config/settings.py b/backend/config/settings.py index 4ec1db3..4e99e8b 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -153,7 +153,7 @@ SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 'ROTATE_REFRESH_TOKENS': True, - 'BLACKLIST_AFTER_ROTATION': True, + 'BLACKLIST_AFTER_ROTATION': False, 'AUTH_HEADER_TYPES': ('Bearer',), } diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 68846ec..020450b 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { Navigate } from 'react-router-dom'; import { useAuthStore } from '../store/auth'; @@ -13,8 +14,35 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi const isLoading = useAuthStore((s) => s.isLoading); const user = useAuthStore((s) => s.user); const mustChangePassword = useAuthStore((s) => s.mustChangePassword); + const fetchUserInfo = useAuthStore((s) => s.fetchUserInfo); + const retrying = useRef(false); - if (isLoading) { + // If we have a token but user info hasn't loaded, keep retrying + useEffect(() => { + if (!isAuthenticated || user || isLoading) return; + if (retrying.current) return; + retrying.current = true; + + let cancelled = false; + const retry = async () => { + let delay = 500; + while (!cancelled) { + try { + await fetchUserInfo(); + break; // success + } catch { + await new Promise(r => setTimeout(r, delay)); + delay = Math.min(delay * 2, 3000); + } + } + retrying.current = false; + }; + retry(); + + return () => { cancelled = true; }; + }, [isAuthenticated, user, isLoading, fetchUserInfo]); + + if (isLoading || (isAuthenticated && !user)) { return (
{ return config; }); +// Token refresh lock: prevent concurrent refresh requests +let refreshPromise: Promise | null = null; + +function doRefresh(): Promise { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) return Promise.reject(new Error('no_refresh_token')); + + return axios.post('/api/v1/auth/token/refresh', { refresh: refreshToken }) + .then(({ data }) => { + localStorage.setItem('access_token', data.access); + if (data.refresh) { + localStorage.setItem('refresh_token', data.refresh); + } + return data.access as string; + }); +} + +function refreshAccessToken(): Promise { + if (refreshPromise) return refreshPromise; + refreshPromise = doRefresh().finally(() => { refreshPromise = null; }); + return refreshPromise; +} + // Response interceptor: auto-refresh on 401 api.interceptors.response.use( (response) => response, @@ -60,24 +83,13 @@ api.interceptors.response.use( // Auto-refresh on 401 (only for non-ban cases) if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) { originalRequest._retry = true; - const refreshToken = localStorage.getItem('refresh_token'); - if (refreshToken) { - try { - const { data } = await axios.post('/api/v1/auth/token/refresh', { - refresh: refreshToken, - }); - localStorage.setItem('access_token', data.access); - if (data.refresh) { - localStorage.setItem('refresh_token', data.refresh); - } - originalRequest.headers.Authorization = `Bearer ${data.access}`; - return api(originalRequest); - } catch { - localStorage.removeItem('access_token'); - localStorage.removeItem('refresh_token'); - window.location.href = '/login'; - } - } else { + try { + const newAccess = await refreshAccessToken(); + originalRequest.headers.Authorization = `Bearer ${newAccess}`; + return api(originalRequest); + } catch { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); window.location.href = '/login'; } } diff --git a/web/src/pages/AuditLogsPage.tsx b/web/src/pages/AuditLogsPage.tsx index 431cf95..dc31109 100644 --- a/web/src/pages/AuditLogsPage.tsx +++ b/web/src/pages/AuditLogsPage.tsx @@ -28,7 +28,8 @@ const FIELD_LABELS: Record = { default_monthly_seconds_limit: '每月限额(秒)', default_daily_generation_limit: '每日生成次数', default_monthly_generation_limit: '每月生成次数', - base_token_price: '基础token单价', + base_token_price: '不含视频输入单价', + base_token_price_video: '含视频输入单价', announcement: '公告内容', announcement_enabled: '公告开关', name: '名称', diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 7ddd594..91d1515 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -11,6 +11,7 @@ export function SettingsPage() { default_daily_generation_limit: 50, default_monthly_generation_limit: 500, base_token_price: 0, + base_token_price_video: 0, announcement: '', announcement_enabled: false, max_desktop_sessions: 1, @@ -140,14 +141,25 @@ export function SettingsPage() { />
-
- - setSettings({ ...settings, base_token_price: Number(e.target.value) })} - /> +
+
+ + setSettings({ ...settings, base_token_price: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, base_token_price_video: Number(e.target.value) })} + /> +