diff --git a/apps/api/admin.py b/apps/api/admin.py index 3318b9d..d2ce8f0 100644 --- a/apps/api/admin.py +++ b/apps/api/admin.py @@ -13,6 +13,7 @@ class ImageGenerationTaskInputInline(admin.TabularInline): @admin.register(ImageGenerationTask) class ImageGenerationTaskAdmin(admin.ModelAdmin): + change_form_template = "admin/api/imagegenerationtask/change_form.html" inlines = (ImageGenerationTaskInputInline,) list_display = ( "task_id", diff --git a/apps/api/static/api/admin/image-task-gallery.css b/apps/api/static/api/admin/image-task-gallery.css new file mode 100644 index 0000000..41604cd --- /dev/null +++ b/apps/api/static/api/admin/image-task-gallery.css @@ -0,0 +1,110 @@ +.image-task-gallery { + margin: 20px 0; +} + +.image-task-gallery__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(144px, 1fr)); + gap: 12px; + max-width: 760px; +} + +.image-task-gallery__item { + margin: 0; +} + +.image-task-gallery__trigger { + display: grid; + width: 144px; + height: 144px; + padding: 0; + overflow: hidden; + cursor: zoom-in; + background: #f6f7f8; + border: 1px solid var(--hairline-color, #d8dadd); + border-radius: 6px; + place-items: center; +} + +.image-task-gallery__trigger:focus-visible, +.image-task-preview-dialog__close:focus-visible { + outline: 3px solid var(--focus-color, #417690); + outline-offset: 2px; +} + +.image-task-gallery__trigger img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.image-task-gallery__trigger.is-unavailable img { + display: none; +} + +.image-task-gallery__unavailable { + padding: 12px; + color: var(--body-quiet-color, #666); + font-size: 12px; + text-align: center; +} + +.image-task-gallery__item figcaption { + margin-top: 6px; + color: var(--body-quiet-color, #666); + font-size: 12px; +} + +.image-task-preview-dialog { + width: min(960px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + padding: 16px; + color: var(--body-fg, #333); + background: var(--body-bg, #fff); + border: 0; + border-radius: 6px; + box-shadow: 0 16px 48px rgb(0 0 0 / 30%); +} + +.image-task-preview-dialog::backdrop { + background: rgb(0 0 0 / 55%); +} + +.image-task-preview-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 12px; +} + +.image-task-preview-dialog__header h2 { + margin: 0; + font-size: 16px; +} + +.image-task-preview-dialog__close { + min-width: 32px; + min-height: 32px; + padding: 4px 8px; + color: var(--button-fg, #fff); + cursor: pointer; + background: var(--button-bg, #417690); + border: 0; + border-radius: 4px; +} + +.image-task-preview-dialog__image { + display: block; + width: auto; + max-width: 100%; + max-height: calc(100vh - 136px); + margin: 0 auto; + object-fit: contain; +} + +.image-task-preview-dialog__error { + margin: 0; + color: var(--error-fg, #ba2121); +} diff --git a/apps/api/static/api/admin/image-task-gallery.js b/apps/api/static/api/admin/image-task-gallery.js new file mode 100644 index 0000000..6a82387 --- /dev/null +++ b/apps/api/static/api/admin/image-task-gallery.js @@ -0,0 +1,60 @@ +(function () { + "use strict"; + + function markImageUnavailable(image) { + const trigger = image.closest(".image-task-gallery__trigger"); + if (!trigger) { + return; + } + + trigger.classList.add("is-unavailable"); + const message = trigger.querySelector(".image-task-gallery__unavailable"); + if (message) { + message.hidden = false; + } + } + + document.addEventListener("DOMContentLoaded", function () { + const dialog = document.querySelector("[data-image-preview-dialog]"); + if (!dialog) { + return; + } + + const previewImage = dialog.querySelector("[data-image-preview-image]"); + const previewLabel = dialog.querySelector("[data-image-preview-label]"); + const previewError = dialog.querySelector("[data-image-preview-error]"); + const closeButton = dialog.querySelector("[data-image-preview-close]"); + + document.querySelectorAll(".image-task-gallery__trigger img").forEach(function (image) { + image.addEventListener("error", function () { + markImageUnavailable(image); + }); + }); + + document.querySelectorAll("[data-image-preview]").forEach(function (trigger) { + trigger.addEventListener("dblclick", function () { + previewLabel.textContent = trigger.dataset.imageLabel || "图片预览"; + previewImage.alt = trigger.dataset.imageLabel || "图片预览"; + previewError.hidden = true; + previewImage.hidden = false; + previewImage.src = trigger.dataset.imageUrl; + dialog.showModal(); + }); + }); + + previewImage.addEventListener("error", function () { + previewImage.hidden = true; + previewError.hidden = false; + }); + + closeButton.addEventListener("click", function () { + dialog.close(); + }); + + dialog.addEventListener("click", function (event) { + if (event.target === dialog) { + dialog.close(); + } + }); + }); +})(); diff --git a/apps/api/templates/admin/api/imagegenerationtask/change_form.html b/apps/api/templates/admin/api/imagegenerationtask/change_form.html new file mode 100644 index 0000000..89859f5 --- /dev/null +++ b/apps/api/templates/admin/api/imagegenerationtask/change_form.html @@ -0,0 +1,85 @@ +{% extends "admin/change_form.html" %} +{% load static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock %} + +{% block after_field_sets %} +{{ block.super }} +{% if original %} + {% with input_images=original.input_images.all %} + {% if input_images or original.input_image or original.status == "succeeded" and original.result_url %} + + {% endif %} + {% endwith %} + + +
+

图片预览

+ +
+ + +
+{% endif %} +{% endblock %} + +{% block admin_change_form_document_ready %} +{{ block.super }} + +{% endblock %} diff --git a/apps/api/tests.py b/apps/api/tests.py index 5d09f7e..4ecd52e 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -14,9 +14,10 @@ from django.conf import settings from django.contrib import admin from django.contrib.auth import get_user_model from django.core.cache import cache +from django.core.files.base import ContentFile from django.core.management import call_command from django.test import TestCase, override_settings -from django.urls import path +from django.urls import path, reverse from django.utils import timezone from rest_framework.response import Response from rest_framework.test import APIClient @@ -696,6 +697,111 @@ class ClientLatestReleaseApiTests(TestCase): self.assertIn("size_bytes", download_fields) +class ImageGenerationTaskAdminTests(TestCase): + def setUp(self): + self.media_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.media_dir.cleanup) + self.media_override = override_settings(MEDIA_ROOT=self.media_dir.name) + self.media_override.enable() + self.addCleanup(self.media_override.disable) + + suffix = uuid.uuid4().hex[:8] + user_model = get_user_model() + self.admin_user = user_model.objects.create_superuser( + username=f"image-task-admin-{suffix}", + email=f"image-task-admin-{suffix}@example.com", + password="test-password", + ) + self.user = user_model.objects.create_user( + username=f"image-task-user-{suffix}", + email=f"image-task-user-{suffix}@example.com", + password="test-password", + ) + self.api_key, _raw_key = ApiKey.create_for_user(self.user, name="image-task-admin-test") + self.client.force_login(self.admin_user) + + def create_task(self, *, status=ImageGenerationTask.Status.SUCCEEDED, result_url=""): + call_record = CallRecord.objects.create( + user=self.user, + api_key=self.api_key, + operation_type=CallRecord.OperationType.IMAGE, + alias="image-standard", + model_used="test-image-model", + resolution="1K", + prompt="生成商品主图", + points_cost=10, + status=CallRecord.Status.SUCCESS, + ) + return ImageGenerationTask.objects.create( + user=self.user, + api_key=self.api_key, + call_record=call_record, + status=status, + request_hash=uuid.uuid4().hex + uuid.uuid4().hex, + result_url=result_url, + points_balance_after_charge=90, + ) + + def change_url(self, task): + return reverse("admin:api_imagegenerationtask_change", args=(task.pk,)) + + def add_input_image(self, task, *, ordinal, filename): + task_input = ImageGenerationTaskInput( + task=task, + ordinal=ordinal, + mime_type="image/png", + filename=filename, + ) + task_input.image.save(filename, ContentFile(b"test-image"), save=True) + return task_input + + def test_change_view_shows_ordered_input_and_result_thumbnails_with_modal_preview(self): + task = self.create_task(result_url="https://images.example.test/generated.png") + main = self.add_input_image(task, ordinal=0, filename="main.png") + reference = self.add_input_image(task, ordinal=1, filename="reference.png") + + response = self.client.get(self.change_url(task)) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "图片预览") + self.assertContains(response, "主图") + self.assertContains(response, "参考图 1") + self.assertContains(response, "生成结果") + self.assertContains(response, main.image.url) + self.assertContains(response, reference.image.url) + self.assertContains(response, task.result_url) + self.assertContains(response, "data-image-preview-dialog") + self.assertContains(response, "image-task-gallery.js") + self.assertContains(response, "双击查看大图") + + def test_change_view_uses_legacy_single_input_image_as_main_image(self): + task = self.create_task() + task.input_image.save("legacy-main.png", ContentFile(b"legacy-image"), save=True) + + response = self.client.get(self.change_url(task)) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "主图") + self.assertContains(response, task.input_image.url) + self.assertNotContains(response, "参考图 1") + + def test_change_view_handles_task_without_images_or_result(self): + task = self.create_task(status=ImageGenerationTask.Status.QUEUED) + + response = self.client.get(self.change_url(task)) + + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, 'id="image-task-gallery-title"') + + def test_change_view_requires_staff_access(self): + task = self.create_task() + self.client.force_login(self.user) + + response = self.client.get(self.change_url(task)) + + self.assertEqual(response.status_code, 302) + + @override_settings( PAYMENT_CALLBACK_MODE="mock", PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret", diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 9cf9fd9..a933b43 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -40,7 +40,7 @@ 当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送试用点数(当前 10 点)」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、T-612「生图同步接口止血」、T-613「抽生成核心 service」、T-614「生图异步任务化接口」与 T-615「旧同步生图接口遥测 / 弃用口径」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615 并完成:T-612 已做同步接口上游硬截止止血,T-613 已抽共享生成 core,T-614 已新增异步提交 / 轮询接口与 DB worker,T-615 已给旧同步 / 新异步提交路径接入结构化日志并形成旧同步接口退出条件。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。 -T-616~T-620 也已完成:生图失败自动重试、客户端版本文件校验元数据、多图理解 `vision` 操作与同步接口,以及单图 / 多图图生图均已落地。T-620 保持旧单图字段兼容,并固定第一张为主图、后续为参考图;生产启用多图理解前仍需在 admin 配置视觉模型、默认别名和计费规则。 +T-616~T-620 与 T-622 也已完成:生图失败自动重试、客户端版本文件校验元数据、多图理解 `vision` 操作与同步接口、单图 / 多图图生图,以及 django-admin 图片生成任务详情页的输入图 / 结果图缩略预览与双击大图查看。T-620 保持旧单图字段兼容,并固定第一张为主图、后续为参考图;T-622 不改接口或账务。生产启用多图理解前仍需在 admin 配置视觉模型、默认别名和计费规则。 优先路径: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index e77282e..f691586 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -100,6 +100,7 @@ | T-618 | 客户端发布版本后台必填文件校验元数据 | T-617 | 把 django-admin 里 `DownloadRelease` 的 `sha256` 和 `size_bytes` 改为必填,避免运营发布客户端安装包时漏填校验元数据,导致桌面端无法完整校验下载文件。**范围**:本任务只要求 admin 后台保存时必填;为兼容历史数据和现有 API 合约,第一步不直接把数据库字段改成 `NOT NULL`,`size_bytes` 仍允许旧记录为空,API 暂保持可返回 `null`;若后续要数据库级强约束,需先单独回填全量历史发布记录再做迁移。**实现**:`DownloadReleaseAdmin` 已挂专用 `ModelForm`,将 `sha256`、`size_bytes` 设为 required,并保留 `sha256` 64 位十六进制校验、`size_bytes >= 1` 校验;admin 新增 / 编辑保存时未填会显示字段级错误,不写库。**生产数据**:上线后必须补齐当前 `windows` 发布版本的 `size_bytes`,再验证 `/api/v1/client/releases/latest?platform=windows` 返回正整数 `release.size_bytes`;已有 `sha256` 继续保留并核对。**兼容性**:不删除既有 API 字段,不改变无当前版本时的 `release:null`;非 admin 的历史数据、迁移和只读接口不应因空值直接 500。**文档**:同步 `api.md`、`04-architecture.md`、`routes.md`、`current-state.md` 和 `progress.md` 的发布校验口径。**测试**:已覆盖 admin 表单缺 `sha256` / 缺 `size_bytes` 拒绝保存、合法 `sha256 + size_bytes` 可保存、API 仍能读取历史空值记录、当前版本补齐后接口返回正整数;`check` / `makemigrations --check` / 目标测试通过并在 `../progress.md` 留证据 | DONE | | T-619 | 多张图片理解并返回文字 | T-613, T-601, T-306 | 新增独立的多模态图片理解能力,不复用“生成标题”语义。**操作与别名**:在 `ModelAlias.OperationType`、`CallRecord.OperationType` 和别名解析中新增 `vision`;建议首个数据库别名为 `vision-standard`,但不得在接口中暴露具体供应商模型名。`vision` 别名指向的 `AiModel` 及 Provider 必须同时具备 `vision` 与文字输出能力,图片生成 / 编辑专用 Provider 不得误接。**新增接口**:`POST /api/v1/analyze/images`,继承 `ExternalApiView`、使用 API Key 鉴权和生成限流,同步返回 `{text, alias, model_used, points_cost, points_balance, call_id}`;不改变 `/api/v1/generate/title`、`/api/v1/generate/image` 及异步生图接口。**请求结构**:`prompt` 必填,`model` 可选,`images` 为有序列表且至少 1 张;每项必须且只能提供一个 `image_url` 或 `image_base64`,允许 URL/base64 混合。新增 `VISION_MAX_IMAGES=8`、`VISION_MAX_IMAGE_BYTES=10485760`、`VISION_MAX_TOTAL_BYTES=33554432` 三个可配置上限,分别控制单次图片数量、单图解码后字节数和总字节数;超限、空列表、格式错误或同项双来源返回 `400 bad_request`,不扣点、不调上游。**安全时序**:serializer 后先按 T-604 审核 prompt,再下载 URL / 解码 base64;每个 URL 必须复用 T-306 的协议、公网地址、重定向逐跳校验、超时和响应大小保护,不另写弱化下载器。第一版只审核文字 prompt,不声称已做图片内容审核。**Provider**:复用 `apps/ai/providers` 现有 HTTP 适配层,增加向 Chat Completions / Gemini 发送多张图片并保序的兼容能力;不得复制第二套上游调用实现。现有单图标题 / 生图调用签名与行为保持兼容。**计费与留痕**:复用 T-613 共享生成 core 与 billing 的预扣 / 成功确认 / 幂等退点;第一版按 `operation_type=vision + alias` 的默认 `PricingRule` 对一次请求固定扣点,不按图片张数重复扣费,图片数量上限用于控制成本;上游失败必须退点。`CallRecord` 记录 `vision`、别名、实际模型、点数、耗时和结果摘要,不保存输入图片、base64、provider raw 或完整上游响应。**目录与运营配置**:`GET /api/v1/models` 和 portal 可用模型页按 T-601 既有语义列出 active、能力匹配的 `vision` 别名并显示 `requires_image=true`;有定价时返回 `priced`,缺定价时仍返回 `unpriced`,不得改变现有目录兼容行为。代码部署 / migrate 后由运营在 admin 配置支持视觉理解的 `AiModel`、`vision-standard` 默认别名和计费规则,不通过数据迁移写入真实上游配置或密钥。**范围边界**:本任务只做同步文字结果,不做流式输出、异步 vision task、OCR 专用接口、结构化 JSON schema、图片内容审核,也不为 OCR / 商品识别 / 图片对比各建别名;这些用途先由 prompt 表达,只有底层模型或价格确实不同时再增加别名。**文档**:实现时同步 `02-requirements.md`、`04-architecture.md`、`api.md`、`routes.md`、`env.md`、`current-state.md` 和 `progress.md`。**测试**:覆盖单图 / 多图 / 混合来源及顺序、默认 / 指定别名、模型与 Provider 能力拒绝、图片数量与大小限制、SSRF、敏感词先拦截、成功仅扣一次、上游失败仅退一次、调用记录不保存图片 / raw、模型目录安全字段、旧标题 / 生图接口回归;`makemigrations` / `migrate` / `check` / 目标测试 / `init` 通过并在 `../progress.md` 留证据 | DONE | | T-620 | 图生图支持单图 / 多图主图与参考图 | T-613, T-614, T-616, T-619 | 已完成:同步 / 异步图生图支持有序 `images`,旧单图字段继续兼容且禁止混用;服务端固定注入首图主商品、后续参考图规则。新增 `IMAGE_MAX_INPUT_IMAGES` / `IMAGE_MAX_INPUT_IMAGE_BYTES` / `IMAGE_MAX_INPUT_TOTAL_BYTES`,图片 URL 同时受新单图限制与既有 `IMAGE_URL_MAX_BYTES` 的较小值保护。Provider 已验证 Chat 多段、JSON `image_urls` 与 `images_edits` 重复 multipart `image` 的顺序传递;异步任务新增 `ImageGenerationTaskInput` 有序输入表,旧 `input_image` 读取保持兼容。已通过 `check`、迁移一致性、13 条 Provider 测试、序列化器边界校验与 6 条目标 API 测试(同步多图 / URL+base64 混合 / 互斥输入 / 超限 / 异步恢复 / URL 上限);完整 `GenerateApiTests` 曾跑出 53 条且发现并修复 URL 上限回归,远程 MySQL 测试库后续整类重跑超过 5 分钟未完成。此前已使用真实中转站 `gpt-image-2` 对两张图片重复 multipart `image` 调用获得 HTTP 200 与图片结果,确认上游多文件传输契约。详见 `progress.md`。 | DONE | +| T-622 | 图片生成任务后台图片缩略预览 | T-614, T-620 | 已完成:django-admin 的 `ImageGenerationTask` 详情页新增输入图 / 结果图缩略画廊;输入按 `ordinal` 标为主图和参考图,兼容历史 `input_image` 单图;成功任务以 `result_url` 展示生成结果。双击缩略图弹出后台 `` 大图预览,支持关闭按钮、遮罩和 `Escape`;图片加载失败显示降级提示。未改 `CallRecord`、对外 API、计费、worker、迁移、媒体访问策略或列表页查询。已通过 4 条目标 admin 测试、`check`、迁移一致性、静态资源发现和 `init.ps1`。 | DONE | ## 里程碑 @@ -119,6 +120,7 @@ - M14:客户端发布版本后台必填校验元数据(T-618)。 - M15:多张图片理解并返回文字(T-619)。 - M16:图生图支持单图 / 多图主图与参考图(T-620)。 +- M17:图片生成任务后台图片缩略预览(T-622)。 ## 待办池(Backlog) diff --git a/docs/current-state.md b/docs/current-state.md index a995fab..9683bde 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,7 +11,7 @@ ## 当前快照 -- 日期:2026-07-18 +- 日期:2026-07-20 - 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-603 django-admin 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送试用点数(当前 10 点)、T-609 桌面端版本检查接口增加强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为虾皮圈、T-612 生图同步接口止血、T-613 抽生成核心 service、T-614 生图异步任务化接口、T-615 旧同步生图接口遥测 / 弃用口径、T-616 生图失败自动重试 2 次、T-617 桌面端版本文件大小字段与 T-618 客户端发布版本后台必填校验元数据已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和生产侧旧同步接口用量观察 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + ahocorapy 1.6.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` - 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501/T-608 已接入 allauth 注册登录路径,注册成功后 adapter 调用 `grant_signup_bonus()` 经 billing 一次性发放 10 点并写 `signup_bonus` 流水,新增 `SignupBonusGrant(user UNIQUE)` 幂等标记、admin 只读检索和 `billing.0007` 迁移;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503/T-608 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 点数记录,只读展示当前用户数据和注册赠点 / 消费 / 退款流水;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 点数记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水、注册赠点记录和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL、HSTS 环境变量与 `ACCOUNT_SIGNUP_RATE_LIMIT` 注册限流配置;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游;T-606 已新增公开首页 `/`、`DownloadRelease` 模型/admin/迁移、首页 SSR 模板、共享 `portal/brand.css`,并把现有 portal 页面套入同一套品牌 token;T-607/T-609 已新增 `ClientLatestReleaseView` 与 `/api/v1/client/releases/latest`,公开匿名返回当前客户端版本 JSON,`release.force_update` 表示该版本是否强制升级;`portal.0002_downloadrelease_force_update` 已给 `DownloadRelease` 增加 `force_update` 字段,admin 可编辑和筛选;T-610 已新增 `ImportTemplate` 模型/admin/迁移 `portal.0003_importtemplate`,首页读取当前模板并在“下载客户端”旁展示“下载导入模板”,本地文件 URL 转为当前站点绝对 URL,`external_url` 优先;T-611 已把用户端 portal 可见品牌名统一为“虾皮圈”,包括页面标题、顶部导航、首页 H1、用户端“虾皮圈 API Key”文案和 allauth 邮件模板;T-612 已新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,生图 Provider 上游请求和上游返回图片 URL 下载会按 `min(AiModel.timeout_seconds 或分辨率默认值, 硬截止)` 控制读取超时,超时返回 `upstream_timeout` 并走既有失败退点路径;T-613 已把旧同步生成链路抽成 `GenerationInput`、`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()` 与 `GenerationResult`,旧 view 只负责 serializer 和异常转 HTTP,后续异步 worker 可复用已预扣执行 / 确认 / 退点阶段。 @@ -74,9 +74,9 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 - 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。 -- 已完成补充:T-619 多张图片理解并返回文字。 +- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览。 - 正在进行:无。 -- 待开始:任务看板当前没有已登记的 TODO;T-621 注册赠点运营后台配置已移入 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 +- 待开始:任务看板当前没有已登记的 TODO;T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 - 下一个可领取任务:暂无;需先将新的工作项登记为任务后再领取。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 diff --git a/progress.md b/progress.md index 269cbd5..ad1c289 100644 --- a/progress.md +++ b/progress.md @@ -2026,3 +2026,16 @@ - 安全:仍复用既有 `SignupBonusGrant(user UNIQUE)` 数据库级幂等标记和 billing 事务写账;此次只调整额度,不改变注册接入、钱包锁、流水或 API 契约。 - 本地验证:`C:/Python312/python.exe manage.py migrate --noinput` 已应用 `billing.0009_signup_bonus_default_ten`;`manage.py check` 0 issues;`makemigrations --check --dry-run` 无待生成迁移;`compileall` 通过。定向 MySQL 测试 5 条通过:服务默认发放 / 已有钱包累加 / 并发仅发一次 / 注册 adapter 写余额与流水 / 首页匿名文案。测试仅出现既有 allauth 条件唯一约束在 MySQL 上不创建的 `models.W036` 警告。 - 线上部署:已部署代码提交 `83fa541` 至 `185.216.248.75:/www/wwwroot/cmhub`,同步时保留 `.env`、媒体、静态文件和部署配置,并创建服务器侧配置/媒体备份。部署前确认无 `queued` / `running` 图片任务;`cmhub-web`、`cmhub-generate` 及 66 个 `cmhub-image-worker@*` 均已重启并为 `active`,无 cmhub / Nginx failed unit。线上已应用 `billing.0009_signup_bonus_default_ten`,迁移记录为已登记,`manage.py check` 通过;运行时代码和模型字段默认值均为 10,HTTPS 首页返回 200。 + +## 2026-07-20 文档登记:T-622 图片生成任务后台图片缩略预览 + +- 状态:TODO,仅登记任务,未改代码、数据库、接口、计费、worker 或线上服务。 +- 范围:在 django-admin 的 `ImageGenerationTask` 详情页复用已存的 `ImageGenerationTaskInput`(主图与参考图)及 `result_url`,展示稳定尺寸缩略图;双击后以后台模态窗口查看大图。 +- 边界:不改 `CallRecord`,不为旧同步生图补建任务或补存输入图,不新增公开接口或媒体访问策略;列表页不加载缩略图,避免扩大查询和带宽开销。 + +## 2026-07-20 完成:T-622 图片生成任务后台图片缩略预览 + +- 实现:`ImageGenerationTaskAdmin` 使用专用 change form 模板,仅在详情页渲染缩略画廊。多图输入按 `ordinal` 标识第 1 张主图及后续参考图;无子输入时回退展示历史 `input_image`;成功任务有 `result_url` 时展示生成结果。缩略图双击打开原生 `` 大图预览,支持关闭按钮、点击遮罩和 `Escape`;缩略图或大图加载失败均有前端降级提示。 +- 边界:未修改模型、迁移、`CallRecord`、API 契约、计费、worker 或媒体访问策略;任务列表不渲染缩略图。新增静态 CSS / JS 仅由任务详情模板加载。 +- 验证:`C:/Python312/python.exe manage.py check` 通过;`manage.py test apps.api.tests.ImageGenerationTaskAdminTests --keepdb --noinput --verbosity 2` 通过,4 tests OK(多图+结果、历史单图、无图片任务、非 staff);`makemigrations --check --dry-run` 无待生成迁移;`findstatic` 找到 CSS / JS;`./init.ps1` 通过;`git diff --check` 通过。目标测试仅有既有 allauth MySQL 条件唯一约束 `models.W036` 警告。 +- 线上部署:当前未提交的 T-622 工作区代码已同步到 `185.216.248.75:/www/wwwroot/cmhub`,同步保留 `.env`、媒体、静态文件与部署配置,并创建部署前配置/静态文件备份。线上 `manage.py check` 通过,`collectstatic --noinput` 新复制 7 个静态文件,`cmhub-web` 重启后为 `active`;`cmhub-generate` 未重启,未影响 66 个生图 worker。已确认任务详情模板及 `staticfiles/api/admin/image-task-gallery.css`、`image-task-gallery.js` 存在。