合并修改

This commit is contained in:
sjy
2026-07-22 14:34:07 +08:00
17 changed files with 871 additions and 110 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# VITE_API_BASE=http://192.168.120.17:8000
#VITE_API_BASE=https://apiforeign.minzhong.cn
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY=
File diff suppressed because one or more lines are too long
+36 -36
View File
@@ -1,37 +1,37 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-BLkGzXo7.js"></script>
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<title>后台管理</title>
<script>
(function() {
var cached = localStorage.getItem('siteInfo');
if (cached) {
try {
var info = JSON.parse(cached);
if (info.siteName) {
document.title = info.siteName + ' - 管理后台';
}
if (info.siteLogo) {
var link = document.querySelector('link[rel="icon"]');
if (link) {
link.href = info.siteLogo;
link.type = 'image/png';
}
}
} catch (e) {}
}
})();
</script>
<script type="module" crossorigin src="/assets/index-MWrMIMSc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>
+26 -2
View File
@@ -48,6 +48,8 @@ const AdminSettings: React.FC = () => {
setConfigs(data);
const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; });
// 预扣积分默认值
if (!formValues.optimize_hold_credits) formValues.optimize_hold_credits = '5';
formValues.resource_capacity_enabled = capacity.enabled;
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
@@ -69,6 +71,18 @@ const AdminSettings: React.FC = () => {
await updateSystemConfig(config.id, String(newVal ?? ''));
}
}
// AI创作预扣积分 - 不存在则创建
const holdVal = values.optimize_hold_credits;
if (holdVal !== undefined && holdVal !== null && holdVal !== '') {
const existing = configs.find(c => c.key === 'optimize_hold_credits');
if (existing) {
if (String(holdVal) !== existing.value) {
await updateSystemConfig(existing.id, String(holdVal));
}
} else {
await createSystemConfig('optimize_hold_credits', String(holdVal), '提示词理解预扣积分数量(防止并发超卖)');
}
}
await saveGlobalResourceCapacity({
enabled: !!values.resource_capacity_enabled,
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
@@ -179,6 +193,7 @@ const AdminSettings: React.FC = () => {
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
'其他配置': configs.filter(c => c.key === 'operation_manual'),
'AI创作配置': configs.filter(c => c.key === 'optimize_hold_credits'),
};
const getFieldDescription = (config: SystemConfig): string => {
@@ -194,6 +209,7 @@ const AdminSettings: React.FC = () => {
user_login_credits: '用户每日登录赠送的积分数量',
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
optimize_hold_credits: 'AI创作时预扣积分数量,用于防止并发超卖。预扣后按实际消耗多退少补',
};
return descMap[config.key] || config.description || '';
};
@@ -323,8 +339,8 @@ const AdminSettings: React.FC = () => {
</div>
);
}
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
return <Input type="number" min={0} placeholder={config.description} size="large" />;
if (config.key === 'user_register_credits' || config.key === 'user_login_credits' || config.key === 'optimize_hold_credits') {
return <Input type="number" min={1} placeholder={config.description} size="large" />;
}
return <Input placeholder={config.description} size="large" />;
};
@@ -384,6 +400,14 @@ const AdminSettings: React.FC = () => {
{getFieldComponent(config)}
</Form.Item>
))}
{/* AI创作预扣积分 - 固定显示 */}
<Form.Item
name="optimize_hold_credits"
label={<span style={{ fontWeight: 500 }}></span>}
extra="AI创作时预扣积分数量,用于防止并发超卖。预扣后按实际消耗多退少补"
>
<Input type="number" min={1} placeholder="默认5" size="large" />
</Form.Item>
</div>
</Form>
),
+2 -1
View File
@@ -342,7 +342,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video"
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video", "optimize_hold_credits"
]))
)
configs = result.scalars().all()
@@ -366,6 +366,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
"operation_manual": info.get("operation_manual", ""),
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
"optimize_hold_credits": int(info.get("optimize_hold_credits") or 5),
}
+20 -2
View File
@@ -10,11 +10,11 @@ from fastapi.responses import RedirectResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.system_config import SystemConfig
from app.schemas.generation import (
OptimizeParams,
GenerateParams,
@@ -32,7 +32,7 @@ from app.services.generation.pipeline.db_lock_service import (
DatabaseRowLockBusy,
execute_with_lock_timeout,
)
from app.services.credits import deduct_credits, calc_text_credits
from app.services.credits import deduct_credits, add_credits, calc_text_credits
from app.services.llm import optimize_prompt
from app.services.video_url import generate_temp_url, validate_and_get_record_id, get_video_stream_url
from app.services.resource_accounting_service import (
@@ -271,6 +271,17 @@ async def optimize(
db: AsyncSession = Depends(get_db),
):
record = None
# 预扣积分(SELECT FOR UPDATE 锁行 + 检查 + 扣费,防止并发超卖)
hold_credits = 5
_hold_cfg = await db.execute(select(SystemConfig).where(SystemConfig.key == "optimize_hold_credits").limit(1))
_hold_row = _hold_cfg.scalar_one_or_none()
if _hold_row and _hold_row.value:
try:
hold_credits = int(_hold_row.value)
except (ValueError, TypeError):
hold_credits = 5
await deduct_credits(db, current_user.id, hold_credits, "AI创作预扣积分", biz_key=f"optimize_hold:{req.idempotency_key or 'nokey'}")
await db.commit()
# Validate parameters based on generation type
if req.gen_type == GenerationType.video:
if req.duration not in DURATIONS:
@@ -374,6 +385,13 @@ async def optimize(
db, token_usage["input_tokens"], token_usage["output_tokens"],
)
# 积分结算:预扣金额按实际消耗多退少补
if text_credits < hold_credits:
await add_credits(db, current_user.id, round(hold_credits - text_credits, 2), f"提示词优化积分退还- {project.name}", biz_key=f"optimize_refund:{req.idempotency_key or 'nokey'}")
elif text_credits > hold_credits:
await deduct_credits(db, current_user.id, round(text_credits - hold_credits, 2), f"提示词优化补扣积分 - {project.name}", biz_key=f"optimize_topup:{req.idempotency_key or 'nokey'}")
await db.commit()
failed_record_id = record.id
failed_user_id = current_user.id
try:
+1 -1
View File
@@ -129,7 +129,7 @@ class Settings(BaseSettings):
CAPTCHA_ENABLED: bool = True
BASE_URL: str = "http://ceshi.apiforeign.minzhong.cn"
BASE_URL: str = "https://ceshi.apiforeign.minzhongzc.com"
CORS_ORIGINS: list[str] = ["*"]
+1
View File
@@ -185,6 +185,7 @@ async def _seed_data():
("user_login_credits_enabled", "false", "启用每日登录赠送积分"),
# Operation manual
("operation_manual", "", "操作手册链接"),
("optimize_hold_credits", "5", "AI创作预扣积分数量(防止并发超卖)"),
]
for key, value, desc in configs:
existing = await db.execute(
@@ -68,7 +68,7 @@ async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | Non
if ref_type == "image":
parts.append({"type": "image_url", "image_url": {"url": url}})
elif ref_type == "video":
parts.append({"type": "video_url", "video_url": {"url": url, "fps": settings.CHATAPI_VIDEO_FPS}})
parts.append({"type": "video_url", "video_url": {"url": url}})
return parts
@@ -742,8 +742,8 @@ def build_user_message(user_content: str, references: list[dict[str, str]], refe
content_parts.append({"type": "image_url", "image_url": {"url": ref_url}})
log_content_parts.append({"type": "image_url", "image_url": {"url": ref_url}})
elif ref_type == "video":
content_parts.append({"type": "video_url", "video_url": {"url": ref_url, "fps": reference_video_fps}})
log_content_parts.append({"type": "video_url", "video_url": {"url": ref_url, "fps": reference_video_fps}})
content_parts.append({"type": "video_url", "video_url": {"url": ref_url}})
log_content_parts.append({"type": "video_url", "video_url": {"url": ref_url}})
return {"role": "user", "content": content_parts}, {"role": "user", "content": log_content_parts}
+5 -14
View File
@@ -1,6 +1,4 @@
import base64
import json
import mimetypes
import os
from datetime import datetime
@@ -292,22 +290,15 @@ async def _call_openai_compatible(
def _file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
"""
Convert local upload path to base64 data URI.
Keep remote http/https/data URLs as-is.
远程 URL 保持原样;本地上传路径拼接 BASE_URL 转为完整链接。
"""
if file_url.startswith(("http://", "https://", "data:")):
return file_url
# Compatible with /uploads/xxx and plain relative paths
relative_path = file_url.replace("/uploads/", "", 1).lstrip("/")
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, relative_path)
mime = mimetypes.guess_type(file_path)[0] or fallback_mime
with open(file_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:{mime};base64,{b64}"
# 本地上传路径拼接 BASE_URL,例如 /uploads/images/xxx.png → https://domain.com/uploads/images/xxx.png
base = settings.BASE_URL.rstrip("/")
path = file_url if file_url.startswith("/") else f"/{file_url}"
return f"{base}{path}"
if image_urls or video_urls:
user_message, log_user_message = await _build_multimodal_content(
@@ -212,9 +212,9 @@ def build_resource_signed_url(
http://www.test6.com/generation/video/a.mp4?from=history&exp=1780000000&sign=xxxx
"""
if not resource_url:
if not resource_url or not resource_url.startswith("/generate/"):
return resource_url
seconds = _get_sign_expire_seconds(expire_seconds)
current_ts = _to_int(now_ts, int(time.time())) if now_ts is not None else int(time.time())
@@ -88,7 +88,6 @@ async def build_user_message(user_text: str, video_url: str, db=None) -> tuple[d
"type": "video_url",
"video_url": {
"url": real_url,
"fps": _video_fps(),
},
},
{"type": "text", "text": user_text},
@@ -98,7 +97,6 @@ async def build_user_message(user_text: str, video_url: str, db=None) -> tuple[d
"type": "video_url",
"video_url": {
"url": video_url,
"fps": _video_fps(),
},
},
{"type": "text", "text": user_text},
+2 -2
View File
@@ -1,7 +1,7 @@
# VITE_API_BASE=http://192.168.120.17:8000
#VITE_API_BASE=https://apiforeign.minzhong.cn
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
#VITE_API_BASE=https://apiforeign.minzhongzc.com
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
VITE_USE_MOCK=false
# Encryption disabled for dev — enable in production
VITE_ENCRYPTION_KEY=
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -28,7 +28,8 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-Dej_BHGv.js"></script>
<script type="module" crossorigin src="/assets/index-mLPAhw5a.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bsz_Xon-.css">
</head>
<body>
+138 -24
View File
@@ -3893,6 +3893,12 @@ const AIChatPage: React.FC = () => {
}}>
{selectedRatio === 'auto' ? '智能' : selectedRatio} · {selectedResolution === '1K' ? '1K标清' : selectedResolution === '2K' ? '2K高清' : '4K超清'} · {width}×{height}
</Text>
{multiGenerationEnabled && (
<>
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#8b5cf6' }}>{generationCount}</span>
</>
)}
<CaretDownOutlined style={{ fontSize: 10, color: '#8b5cf6', marginLeft: 'auto' }} />
</button>
@@ -4153,6 +4159,57 @@ const AIChatPage: React.FC = () => {
</span>
</div>
</div>
{/* 生成数量 */}
{multiGenerationEnabled && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #f0f0f5' }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#667085',
}}>
</Text>
<div style={{ display: 'flex', gap: 6 }}>
{Array.from({ length: effectiveMaxGenerationCount }, (_, i) => i + 1).map((num) => (
<button
key={num}
onClick={() => setGenerationCount(num)}
disabled={effectiveMaxGenerationCount <= 1}
style={{
flex: 1,
height: 42,
borderRadius: 6,
border: generationCount === num
? '2px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: generationCount === num
? '#8b5cf6'
: '#FFFFFF',
cursor: effectiveMaxGenerationCount <= 1 ? 'not-allowed' : 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
opacity: effectiveMaxGenerationCount <= 1 ? 0.5 : 1,
}}
>
<span style={{
fontSize: 14,
fontWeight: 600,
color: generationCount === num
? '#fff'
: '#475467',
}}>
{num}
</span>
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
@@ -4201,6 +4258,12 @@ const AIChatPage: React.FC = () => {
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoResolution?.toUpperCase?.() || videoResolution}</span>
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#2f3440' }}>{videoDuration}</span>
{multiGenerationEnabled && (
<>
<span style={{ width: 1, height: 14, background: '#E7EAF0' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#8b5cf6' }}>{generationCount}</span>
</>
)}
<CaretDownOutlined style={{ fontSize: 10, color: '#667085', marginLeft: 'auto' }} />
</button>
@@ -4351,17 +4414,34 @@ const AIChatPage: React.FC = () => {
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 12px',
backgroundColor: '#F6F7FA',
borderRadius: 6,
}}>
<span style={{ fontSize: 14, fontWeight: 600, color: '#667085' }}>
{videoDuration}
</span>
<input
type="number"
min={Math.min(...engineOptions.durations)}
max={Math.max(...engineOptions.durations)}
value={videoDuration}
onChange={(e) => {
const v = Number(e.target.value);
if (!isNaN(v)) {
const clamped = Math.min(Math.max(v, Math.min(...engineOptions.durations)), Math.max(...engineOptions.durations));
setVideoDuration(clamped);
}
}}
style={{
width: 56,
height: 32,
textAlign: 'center',
border: '1px solid #E7EAF0',
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
color: '#2f3440',
outline: 'none',
}}
/>
<span style={{ fontSize: 12, color: '#98a2b3' }}></span>
</div>
</div>
</div>
{/* 选择分辨率 */}
@@ -4410,6 +4490,57 @@ const AIChatPage: React.FC = () => {
))}
</div>
</div>
{/* 生成数量 */}
{multiGenerationEnabled && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #f0f0f5' }}>
<Text style={{
display: 'block',
marginBottom: 8,
fontSize: 12,
fontWeight: 500,
color: '#667085',
}}>
</Text>
<div style={{ display: 'flex', gap: 6 }}>
{Array.from({ length: effectiveMaxGenerationCount }, (_, i) => i + 1).map((num) => (
<button
key={num}
onClick={() => setGenerationCount(num)}
disabled={effectiveMaxGenerationCount <= 1}
style={{
flex: 1,
height: 42,
borderRadius: 6,
border: generationCount === num
? '2px solid #8b5cf6'
: '1px solid #E7EAF0',
backgroundColor: generationCount === num
? '#8b5cf6'
: '#FFFFFF',
cursor: effectiveMaxGenerationCount <= 1 ? 'not-allowed' : 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
transition: 'all 0.2s',
opacity: effectiveMaxGenerationCount <= 1 ? 0.5 : 1,
}}
>
<span style={{
fontSize: 14,
fontWeight: 600,
color: generationCount === num
? '#fff'
: '#475467',
}}>
{num}
</span>
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
@@ -4417,23 +4548,6 @@ const AIChatPage: React.FC = () => {
</Space>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
{multiGenerationEnabled && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap', height: 34, padding: '0 8px 0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}></Text>
<Select
value={generationCount}
onChange={(value) => setGenerationCount(Number(value || 1))}
disabled={effectiveMaxGenerationCount <= 1}
size="small"
variant="borderless"
style={{ width: 68 }}
options={Array.from({ length: effectiveMaxGenerationCount }, (_, index) => ({
value: index + 1,
label: `${index + 1}`,
}))}
/>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#667085', whiteSpace: 'nowrap', height: 34, padding: '0 12px', borderRadius: 11, background: 'rgba(255, 255, 255, 0.92)', border: '1px solid rgba(231, 234, 240, 0.92)', boxShadow: '0 4px 12px rgba(47, 52, 64, 0.04)' }}>
<Text style={{ fontSize: 13, color: '#667085', fontWeight: 600 }}></Text>
<Text style={{ fontSize: 13, color: '#2f3440', fontWeight: 800 }}>{getEstimatedCredits()}</Text>