feat: v0.14.1 视频参考双单价 + Token刷新防抖 + CSV导出上限
- 计费双单价:含视频输入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) <noreply@anthropic.com>
This commit is contained in:
parent
35ebb55893
commit
f3f8d08b56
@ -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)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -90,7 +90,8 @@ class QuotaConfig(models.Model):
|
|||||||
# ── 计费全局配置(v0.10.0 新增) ──
|
# ── 计费全局配置(v0.10.0 新增) ──
|
||||||
default_daily_generation_limit = models.IntegerField(default=50, verbose_name='默认每日生成次数')
|
default_daily_generation_limit = models.IntegerField(default=50, verbose_name='默认每日生成次数')
|
||||||
default_monthly_generation_limit = models.IntegerField(default=1500, 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)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class SystemSettingsSerializer(serializers.Serializer):
|
|||||||
default_daily_generation_limit = serializers.IntegerField(min_value=0, required=False)
|
default_daily_generation_limit = serializers.IntegerField(min_value=0, required=False)
|
||||||
default_monthly_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 = 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 = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
||||||
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
|
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
|
||||||
|
|||||||
@ -48,6 +48,13 @@ def _safe_int(value, default=0):
|
|||||||
return default
|
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.
|
# Columns added in migration 0003; may not exist in production DB yet.
|
||||||
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
|
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
|
||||||
_m0003_ok = None # None = unknown, True = columns exist, False = missing
|
_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]
|
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
||||||
w, h = get_resolution(aspect_ratio)
|
w, h = get_resolution(aspect_ratio)
|
||||||
estimated_tokens = estimate_tokens(w, h, duration)
|
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 串行化同团队请求 ──
|
# ── 所有额度检查在 transaction 内完成,select_for_update 串行化同团队请求 ──
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@ -427,8 +436,10 @@ def _settle_payment(record, total_tokens):
|
|||||||
if not team:
|
if not team:
|
||||||
return
|
return
|
||||||
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
config = QuotaConfig.objects.get_or_create(pk=1)[0]
|
||||||
actual_cost = calculate_cost(total_tokens, config.base_token_price, team.markup_percentage)
|
has_video_ref = _has_video_reference(record.reference_urls)
|
||||||
base_cost = calculate_base_cost(total_tokens, config.base_token_price)
|
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
|
frozen = record.frozen_amount
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@ -1592,7 +1603,8 @@ def admin_create_user_view(request):
|
|||||||
def admin_records_view(request):
|
def admin_records_view(request):
|
||||||
"""GET /api/v1/admin/records"""
|
"""GET /api/v1/admin/records"""
|
||||||
page = _safe_int(request.query_params.get('page', 1), 1)
|
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()
|
search = request.query_params.get('search', '').strip()
|
||||||
start_date = request.query_params.get('start_date', '').strip()
|
start_date = request.query_params.get('start_date', '').strip()
|
||||||
end_date = request.query_params.get('end_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 — 团管查看本团队消费记录"""
|
"""GET /api/v1/team/records — 团管查看本团队消费记录"""
|
||||||
team = request.user.team
|
team = request.user.team
|
||||||
page = _safe_int(request.query_params.get('page', 1), 1)
|
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()
|
search = request.query_params.get('search', '').strip()
|
||||||
start_date = request.query_params.get('start_date', '').strip()
|
start_date = request.query_params.get('start_date', '').strip()
|
||||||
end_date = request.query_params.get('end_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_daily_generation_limit': config.default_daily_generation_limit,
|
||||||
'default_monthly_generation_limit': config.default_monthly_generation_limit,
|
'default_monthly_generation_limit': config.default_monthly_generation_limit,
|
||||||
'base_token_price': float(config.base_token_price),
|
'base_token_price': float(config.base_token_price),
|
||||||
|
'base_token_price_video': float(config.base_token_price_video),
|
||||||
'announcement': config.announcement,
|
'announcement': config.announcement,
|
||||||
'announcement_enabled': config.announcement_enabled,
|
'announcement_enabled': config.announcement_enabled,
|
||||||
'max_desktop_sessions': config.max_desktop_sessions,
|
'max_desktop_sessions': config.max_desktop_sessions,
|
||||||
|
|||||||
@ -153,7 +153,7 @@ SIMPLE_JWT = {
|
|||||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
|
||||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
'ROTATE_REFRESH_TOKENS': True,
|
'ROTATE_REFRESH_TOKENS': True,
|
||||||
'BLACKLIST_AFTER_ROTATION': True,
|
'BLACKLIST_AFTER_ROTATION': False,
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
import { Navigate } from 'react-router-dom';
|
import { Navigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
@ -13,8 +14,35 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
|||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
|
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 (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|||||||
@ -22,6 +22,29 @@ api.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Token refresh lock: prevent concurrent refresh requests
|
||||||
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
function doRefresh(): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
if (refreshPromise) return refreshPromise;
|
||||||
|
refreshPromise = doRefresh().finally(() => { refreshPromise = null; });
|
||||||
|
return refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
// Response interceptor: auto-refresh on 401
|
// Response interceptor: auto-refresh on 401
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
@ -60,24 +83,13 @@ api.interceptors.response.use(
|
|||||||
// Auto-refresh on 401 (only for non-ban cases)
|
// Auto-refresh on 401 (only for non-ban cases)
|
||||||
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
|
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
try {
|
||||||
if (refreshToken) {
|
const newAccess = await refreshAccessToken();
|
||||||
try {
|
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
||||||
const { data } = await axios.post('/api/v1/auth/token/refresh', {
|
return api(originalRequest);
|
||||||
refresh: refreshToken,
|
} catch {
|
||||||
});
|
localStorage.removeItem('access_token');
|
||||||
localStorage.setItem('access_token', data.access);
|
localStorage.removeItem('refresh_token');
|
||||||
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 {
|
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,7 +28,8 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
default_monthly_seconds_limit: '每月限额(秒)',
|
default_monthly_seconds_limit: '每月限额(秒)',
|
||||||
default_daily_generation_limit: '每日生成次数',
|
default_daily_generation_limit: '每日生成次数',
|
||||||
default_monthly_generation_limit: '每月生成次数',
|
default_monthly_generation_limit: '每月生成次数',
|
||||||
base_token_price: '基础token单价',
|
base_token_price: '不含视频输入单价',
|
||||||
|
base_token_price_video: '含视频输入单价',
|
||||||
announcement: '公告内容',
|
announcement: '公告内容',
|
||||||
announcement_enabled: '公告开关',
|
announcement_enabled: '公告开关',
|
||||||
name: '名称',
|
name: '名称',
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export function SettingsPage() {
|
|||||||
default_daily_generation_limit: 50,
|
default_daily_generation_limit: 50,
|
||||||
default_monthly_generation_limit: 500,
|
default_monthly_generation_limit: 500,
|
||||||
base_token_price: 0,
|
base_token_price: 0,
|
||||||
|
base_token_price_video: 0,
|
||||||
announcement: '',
|
announcement: '',
|
||||||
announcement_enabled: false,
|
announcement_enabled: false,
|
||||||
max_desktop_sessions: 1,
|
max_desktop_sessions: 1,
|
||||||
@ -140,14 +141,25 @@ export function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formRow}>
|
||||||
<label>基础token单价 (元/百万tokens)</label>
|
<div className={styles.formGroup}>
|
||||||
<input
|
<label>不含视频输入单价 (元/百万tokens)</label>
|
||||||
type="number"
|
<input
|
||||||
step="0.01"
|
type="number"
|
||||||
value={settings.base_token_price}
|
step="0.01"
|
||||||
onChange={(e) => setSettings({ ...settings, base_token_price: Number(e.target.value) })}
|
value={settings.base_token_price}
|
||||||
/>
|
onChange={(e) => setSettings({ ...settings, base_token_price: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>含视频输入单价 (元/百万tokens)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={settings.base_token_price_video}
|
||||||
|
onChange={(e) => setSettings({ ...settings, base_token_price_video: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
|
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
|
||||||
{saving ? '保存中...' : '保存配额设置'}
|
{saving ? '保存中...' : '保存配额设置'}
|
||||||
|
|||||||
@ -84,30 +84,37 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchUserInfo: async () => {
|
fetchUserInfo: async () => {
|
||||||
try {
|
const { data } = await authApi.getMe();
|
||||||
const { data } = await authApi.getMe();
|
const { quota, team, team_disabled, ...user } = data;
|
||||||
const { quota, team, team_disabled, ...user } = data;
|
set({
|
||||||
set({
|
user,
|
||||||
user,
|
quota,
|
||||||
quota,
|
team: team || null,
|
||||||
team: team || null,
|
teamDisabled: team_disabled || false,
|
||||||
teamDisabled: team_disabled || false,
|
isAuthenticated: true,
|
||||||
isAuthenticated: true,
|
mustChangePassword: user.must_change_password || false,
|
||||||
mustChangePassword: user.must_change_password || false,
|
});
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Token invalid
|
|
||||||
get().logout();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
initialize: async () => {
|
initialize: async () => {
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
// Retry up to 3 times for network errors (e.g. request aborted during page refresh)
|
||||||
await get().fetchUserInfo();
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
} catch {
|
try {
|
||||||
get().logout();
|
await get().fetchUserInfo();
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
get().logout();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Network error / aborted — wait briefly and retry
|
||||||
|
if (attempt < 2) {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
|
|||||||
@ -209,6 +209,7 @@ export interface SystemSettings {
|
|||||||
default_daily_generation_limit: number;
|
default_daily_generation_limit: number;
|
||||||
default_monthly_generation_limit: number;
|
default_monthly_generation_limit: number;
|
||||||
base_token_price: number;
|
base_token_price: number;
|
||||||
|
base_token_price_video: number;
|
||||||
announcement: string;
|
announcement: string;
|
||||||
announcement_enabled: boolean;
|
announcement_enabled: boolean;
|
||||||
max_desktop_sessions: number;
|
max_desktop_sessions: number;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user