Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ docker compose --env-file .env.prod up -d --force-recreate nginx
# 数据库备份
bash scripts/backup-db.sh

# 镜像冗余清理
docker system prune -a -f

# 证书状态查看
docker compose --env-file .env.prod exec certbot certbot certificates
```
Expand Down
12 changes: 12 additions & 0 deletions backend/admin/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ const nextConfig = {
},
]
},
// basePath=/admin 下,根路径 / 不在 Next.js 路由表内,直接 404。
// 通过 redirects + basePath: false 让 / 跳到 /admin,由 src/app/page.tsx 接力跳到仪表盘。
async redirects() {
return [
{
source: '/',
destination: '/admin',
basePath: false,
permanent: false,
},
]
},
};

module.exports = nextConfig;
11 changes: 11 additions & 0 deletions backend/admin/src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client';

import React from 'react';
import AdminLayout from '@/components/admin/AdminLayout';

// admin 段布局:仅包裹实际 URL 为 /admin/admin/* 的路由(含登录页)。
// 这样根重定向页面 (src/app/page.tsx) 与 not-found 页面不会被 AdminLayout 包裹,
// 避免 404 页面错误地渲染侧边栏。
export default function AdminSegmentLayout({ children }: { children: React.ReactNode }) {
return <AdminLayout>{children}</AdminLayout>;
}
9 changes: 3 additions & 6 deletions backend/admin/src/components/Providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@

import React from 'react';
import { AuthProvider } from '@/context/AuthContext';
import AdminLayout from '@/components/admin/AdminLayout';
import I18nProvider from '@/i18n/I18nProvider';

// AdminLayout 仅包裹 src/app/admin/* 路由(见 src/app/admin/layout.tsx),
// 避免根 / 重定向页面与 not-found 也错误地渲染侧边栏。
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<I18nProvider>
<AuthProvider>
<AdminLayout>
{children}
</AdminLayout>
</AuthProvider>
<AuthProvider>{children}</AuthProvider>
</I18nProvider>
);
}
7 changes: 4 additions & 3 deletions backend/admin/src/lib/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ const NO_REFRESH_PATHS = ['/admin/auth/login', '/admin/auth/refresh', '/admin/au
const redirectToLogin = () => {
if (typeof window === 'undefined') return;
clearAdminSession();
// 避免在 /admin/login 重复跳转造成循环
const onLoginPage = window.location.pathname === '/admin/login';
onLoginPage || (window.location.href = '/admin/login');
// 注意:window.location 不会被 Next.js basePath 加前缀,必须写浏览器实际可见的完整 URL。
// 当前目录结构 src/app/admin/login + basePath=/admin 决定登录页真实 URL 为 /admin/admin/login。
const onLoginPage = window.location.pathname === '/admin/admin/login';
onLoginPage || (window.location.href = '/admin/admin/login');
};

// 跨页签 Token 刷新协调器(admin 专属 key 与 channel)
Expand Down
2 changes: 1 addition & 1 deletion backend/admin/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

26 changes: 25 additions & 1 deletion backend/routers/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
from models import LLMProvider, ToolConfig
from schemas import ImageGenerateRequest, ImageGenerateResponse
from auth import get_current_active_user_or_admin
from services.image_config_adapter import IMAGE_PROVIDER_CAPABILITIES, to_provider_config
from services.image_config_adapter import (
IMAGE_PROVIDER_CAPABILITIES,
IMAGE_MODEL_CAPABILITIES,
to_provider_config,
)
from services.tool_manager.providers.image_gen import (
_IMAGE_GENERATORS,
_TOOL_GEN_PROVIDERS,
Expand Down Expand Up @@ -211,6 +215,26 @@ async def get_image_model_capabilities(
return caps


# ---------------------------------------------------------------------------
# GET /api/images/model-capabilities/{provider_type}/{model} —— 模型级能力
# 在 provider 级能力上叠加 IMAGE_MODEL_CAPABILITIES 中的模型差异(image_sizes / 14 比例 / 参考图上限)
# ---------------------------------------------------------------------------
@router.get("/model-capabilities/{provider_type}/{model:path}")
async def get_image_model_capabilities_by_model(
provider_type: str,
model: str,
current_user=Depends(get_current_active_user_or_admin),
):
"""返回 provider 级能力 + 模型级差异(如 Flash 支持 512、Pro 不支持等)。"""
provider_caps = IMAGE_PROVIDER_CAPABILITIES.get((provider_type or "").lower())
provider_caps or (_ for _ in ()).throw(
HTTPException(status_code=404, detail=f"Image provider {provider_type} not supported")
)
model_caps = IMAGE_MODEL_CAPABILITIES.get(model) or {}
# 模型级覆盖 provider 级(dict 合并语义:模型独有 image_sizes / max_reference_images / supports_thinking 等会附加)
return {**provider_caps, **model_caps}


# ---------------------------------------------------------------------------
# POST /api/images/generate —— 同步图像生成
# ---------------------------------------------------------------------------
Expand Down
11 changes: 8 additions & 3 deletions backend/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,14 @@ class TestConnectionRequest(BaseModel):
# Gemini 3.1 配置 schemas
# ---------------------------------------------------------------------------
class GeminiImageConfig(BaseModel):
"""Gemini 图片生成配置"""
aspect_ratio: Optional[Literal["auto", "16:9", "4:3", "1:1", "3:4", "9:16"]] = None
image_size: Optional[Literal["4K", "2K", "1024", "512", "auto"]] = None
"""Gemini 图片生成配置(与 gemini-3.x-image-preview 官方规格对齐)"""
# Gemini 3 系列支持 14 个比例 + auto;2.5 在模型级能力清单中收窄
aspect_ratio: Optional[Literal[
"auto", "1:1", "1:4", "1:8", "2:3", "3:2", "3:4",
"4:1", "4:3", "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
]] = None
# image_size:官方推荐 1K/2K/4K(Flash 额外支持 512);保留 "1024" 兼容旧请求(在 IMAGE_SIZE_MAP 中映射为 1K)
image_size: Optional[Literal["4K", "2K", "1K", "1024", "512", "auto"]] = None
output_format: Optional[Literal["png", "jpeg", "webp"]] = None # 输出格式
batch_count: Optional[int] = Field(None, ge=1, le=8) # 批量生成数量 (1-8)
# 参考图片数量限制配置
Expand Down
77 changes: 74 additions & 3 deletions backend/services/ark_image_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@

_ARK_DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"

# Seedream 支持的尺寸
_VALID_SIZES = frozenset({"512px", "1K", "2K", "4K"})
# Seedream 支持的尺寸(5.0 新增 3K)
_VALID_SIZES = frozenset({"512px", "1K", "2K", "3K", "4K"})

# Seedream 支持的输出格式(5.0 支持 png/jpeg;4.5/4.0 仅 jpeg)
_VALID_OUTPUT_FORMATS = frozenset({"png", "jpeg"})


@dataclass
class ArkBatchImageConfig:
"""火山方舟 Seedream 批量图片生成配置"""
size: str = "1K" # 512px / 1K / 2K / 4K
size: str = "1K" # 512px / 1K / 2K / 3K / 4K
n: int = 1 # 每个 prompt 生成张数 (1-4)
response_format: str = "url" # url / b64_json
watermark: bool = False
output_format: str = "png" # png / jpeg (仅 Seedream 5.0 支持)


@dataclass
Expand Down Expand Up @@ -94,6 +98,7 @@ async def _generate_single_prompt(
# Seedream 扩展参数通过 extra_body 传递
extra_body: dict[str, Any] = {"watermark": config.watermark}
(config.size in _VALID_SIZES) and extra_body.update(size=config.size)
(config.output_format in _VALID_OUTPUT_FORMATS) and extra_body.update(output_format=config.output_format)
generate_params["extra_body"] = extra_body

response = await client.images.generate(**generate_params)
Expand Down Expand Up @@ -184,3 +189,69 @@ async def _bounded_generate(idx: int, prompt: str) -> ArkSingleImageResult:
)

return batch_result


# ---------------------------------------------------------------------------
# 编辑 / 参考图模式(Seedream 4.0-5.0 支持 image 字段传递参考图)
# ---------------------------------------------------------------------------
async def edit_ark_image(
*,
api_key: str,
base_url: str | None,
model: str,
image_urls: list[str],
prompt: str,
aspect_ratio: str | None = None,
size: str | None = None,
user_id: str | None = None,
**_kw,
) -> str:
"""火山方舟 Seedream 图像编辑/参考图生成。

通过 /api/v3/images/generations 端点 + extra_body.image 字段传递参考图。
单张传 string,多张传 array(与官方文档一致)。

Args:
api_key: 火山方舟 API Key
base_url: API base URL
model: 模型名称
image_urls: 参考图 URL 列表(公开可访问的 URL 或 base64 data URI)
prompt: 编辑/生成指令
aspect_ratio: 宽高比(Seedream 不直接使用,保留签名兼容)
size: 输出尺寸 (1K/2K/3K/4K)
user_id: 用户 ID(用于媒体存储路径)

Returns:
生成图片的本地 URL(/api/media/...),失败返回空字符串
"""
client = AsyncOpenAI(
api_key=api_key,
base_url=base_url or _ARK_DEFAULT_BASE_URL,
)

# 构建 extra_body:image 字段(单张 string,多张 array)
extra_body: dict[str, Any] = {"watermark": False}
image_value = image_urls[0] if len(image_urls) == 1 else image_urls
extra_body["image"] = image_value
safe_size = size or "2K"
(safe_size in _VALID_SIZES) and extra_body.update(size=safe_size)
extra_body["sequential_image_generation"] = "disabled"

generate_params: dict[str, Any] = {
"model": model,
"prompt": prompt,
"n": 1,
"response_format": "url",
"extra_body": extra_body,
}

try:
response = await client.images.generate(**generate_params)
for item in response.data:
url = await _save_result_item(item, "url", user_id=user_id)
url and (logger.info("Ark Seedream edit: SUCCESS → %s", url))
return url if url else ""
except Exception as e:
logger.error("Ark Seedream edit error: %s", e)

return ""
10 changes: 6 additions & 4 deletions backend/services/batch_image_gen.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@


# 配置映射表(避免 if-else)
# 历史 quality=standard 曾输出 "1024",此处兜底映射回官方 "1K"
IMAGE_SIZE_MAP = {
"512": "512px",
"1K": "1K",
"2K": "2K",
"4K": "4K",
"512": "512px",
"1024": "1K", # 历史兼容:旧客户端 / 旧 quality 映射输出
"1K": "1K",
"2K": "2K",
"4K": "4K",
"auto": None,
}

Expand Down
13 changes: 9 additions & 4 deletions backend/services/billing.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,21 +354,26 @@ async def _drain_balance_to_zero(
) -> None:
"""生成已发生但余额不足时,把余额清零并落 underpaid 流水。

使用 WHERE credits == current_balance 的乐观锁,避免与并发扣费冲突;
若并发已把余额改动,跳过本次清零(其他请求已处理)。
使用 WHERE credits > 0 AND credits < attempted_cost 作为守卫条件:
- credits > 0:防止重复清零(并发安全)
- credits < attempted_cost:仅在余额确实不足时清零,若并发充值使余额 >= cost 则跳过

注意:不使用 credits == current_balance 精确相等比较,因为 SQLite 以 REAL(float64)
存储 Numeric 列,浮点精度差异会导致 WHERE 条件不匹配而静默跳过清零。
"""
if current_balance <= 0:
return
stmt_clear = (
update(entity_model)
.where(entity_model.id == entity_id)
.where(entity_model.credits == current_balance)
.where(entity_model.credits > 0)
.where(entity_model.credits < attempted_cost)
.values(credits=0)
.execution_options(synchronize_session="fetch")
)
res = await session.execute(stmt_clear)
if res.rowcount <= 0:
return # 并发竞争:其他请求已扣,无需重复
return # 并发竞争:余额已被其他请求处理,或充值后余额已足够
tx_meta = dict(metadata or {})
tx_meta.update({
"underpaid": True,
Expand Down
Loading