feat: add client latest release api

This commit is contained in:
QiuSW
2026-07-07 08:33:49 +08:00
parent f158a08b9d
commit 5f17235a13
13 changed files with 374 additions and 17 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地与 T-606 公开首页 + 客户端下载入口。用户可通过公开首页进入注册、登录和下载入口;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页消费记录与可用模型;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、调用记录和客户端发布版本,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。详见 [`docs/current-state.md`](docs/current-state.md)。
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口与 T-607 桌面端最新版本检查接口。用户可通过公开首页进入注册、登录和下载入口;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页消费记录与可用模型;桌面端可匿名请求最新客户端版本 JSON;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、调用记录和客户端发布版本,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。详见 [`docs/current-state.md`](docs/current-state.md)。
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+188 -1
View File
@@ -8,6 +8,7 @@ from unittest.mock import patch
import requests
from cryptography.fernet import Fernet
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase, override_settings
@@ -19,7 +20,7 @@ from rest_framework.views import APIView
from apps.api.authentication import ApiKeyAuthentication
from apps.api.throttles import GenerateRateThrottle
from apps.api.views import ExternalApiView, ModelsView
from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView
from apps.ai.models import AiModel, ModelAlias
from apps.ai.providers import (
AiCapabilityError,
@@ -41,6 +42,7 @@ from apps.billing.payment_gateways import (
from apps.billing.services import RechargePayment
from apps.moderation.models import SensitiveWord
from apps.moderation.providers.keyword import reset_keyword_matcher_cache
from apps.portal.models import DownloadRelease
from apps.users.models import ApiKey
from apps.users.models import UserWallet
@@ -404,6 +406,191 @@ class ModelsCatalogApiTests(TestCase):
self.assertEqual(item["prices"], [])
class ClientLatestReleaseApiTests(TestCase):
url = "/api/v1/client/releases/latest"
def setUp(self):
cache.clear()
self.client = APIClient()
def create_release(
self,
*,
platform: str = DownloadRelease.Platform.WINDOWS,
version: str = "1.0.0",
is_current: bool = True,
external_url: str = "https://download.example.com/cmhub-desktop.exe",
file_name: str = "",
sha256: str = "a" * 64,
release_notes: str = "首版 Windows 客户端",
) -> DownloadRelease:
return DownloadRelease.objects.create(
platform=platform,
version=version,
is_current=is_current,
external_url=external_url,
file=file_name,
sha256=sha256,
release_notes=release_notes,
)
def test_latest_release_is_public_without_api_key_and_returns_current_release(self):
release = self.create_release(
version="1.2.3",
external_url="https://download.example.com/cmhub-1.2.3.exe",
sha256="b" * 64,
release_notes="修复下载入口并补充 SHA256",
)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertNotIn(GenerateRateThrottle, ClientLatestReleaseView.throttle_classes)
self.assertEqual(response.data["platform"], "windows")
self.assertEqual(
set(response.data["release"]),
{"version", "download_url", "sha256", "release_notes", "published_at"},
)
self.assertEqual(response.data["release"]["version"], "1.2.3")
self.assertEqual(
response.data["release"]["download_url"],
"https://download.example.com/cmhub-1.2.3.exe",
)
self.assertEqual(response.data["release"]["sha256"], "b" * 64)
self.assertEqual(
response.data["release"]["release_notes"],
"修复下载入口并补充 SHA256",
)
self.assertEqual(
response.data["release"]["published_at"],
timezone.localtime(release.updated_at).isoformat(),
)
def test_latest_release_ignores_web_session_and_does_not_return_user_data(self):
user = get_user_model().objects.create_user(
username="release-session-user",
email="release-session-user@example.com",
password="password",
)
self.create_release()
self.client.force_login(user)
response = self.client.get(self.url, HTTP_AUTHORIZATION="Bearer sk_cmhub_invalid")
self.assertEqual(response.status_code, 200)
response_body = json.dumps(response.data, ensure_ascii=False)
self.assertNotIn(user.username, response_body)
self.assertNotIn(user.email, response_body)
self.assertNotIn("api_key", response_body)
self.assertNotIn("key_hash", response_body)
def test_latest_release_builds_absolute_file_url(self):
self.create_release(
external_url="",
file_name="downloads/cmhub-desktop-1.0.0.exe",
)
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data["release"]["download_url"],
"https://testserver/media/downloads/cmhub-desktop-1.0.0.exe",
)
def test_latest_release_prefers_external_url_over_uploaded_file(self):
self.create_release(
external_url="https://cdn.example.com/cmhub-desktop-1.0.0.exe",
file_name="downloads/local-secret-name.exe",
)
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data["release"]["download_url"],
"https://cdn.example.com/cmhub-desktop-1.0.0.exe",
)
response_body = json.dumps(response.data, ensure_ascii=False)
self.assertNotIn("local-secret-name.exe", response_body)
self.assertNotIn(str(settings.MEDIA_ROOT), response_body)
def test_latest_release_returns_unpublished_when_no_current_release(self):
self.create_release(version="0.9.0", is_current=False)
response = self.client.get(f"{self.url}?platform=windows")
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
{
"platform": "windows",
"release": None,
"message": "暂未发布",
},
)
def test_latest_release_returns_unpublished_when_current_release_has_no_download_url(self):
self.create_release(external_url="", file_name="")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.data["release"])
self.assertEqual(response.data["message"], "暂未发布")
def test_latest_release_rejects_invalid_platform(self):
response = self.client.get(f"{self.url}?platform=android")
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["error"]["code"], "bad_request")
def test_latest_release_supports_non_windows_platform(self):
self.create_release(
platform=DownloadRelease.Platform.MACOS,
version="2.0.0",
external_url="https://download.example.com/cmhub-2.0.0.dmg",
sha256="c" * 64,
release_notes="macOS 客户端",
)
response = self.client.get(f"{self.url}?platform=macos")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["platform"], "macos")
self.assertEqual(response.data["release"]["version"], "2.0.0")
def test_latest_release_response_does_not_expose_internal_fields(self):
self.create_release(
external_url="",
file_name="downloads/cmhub-desktop-1.0.0.exe",
)
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(set(response.data), {"platform", "release"})
self.assertEqual(
set(response.data["release"]),
{"version", "download_url", "sha256", "release_notes", "published_at"},
)
response_body = json.dumps(response.data, ensure_ascii=False)
for forbidden in (
"id",
"is_current",
"created_at",
"updated_at",
"MEDIA_ROOT",
str(settings.MEDIA_ROOT),
"user",
"email",
"api_key",
"api_key_encrypted",
"model_used",
):
self.assertNotIn(forbidden, response_body)
@override_settings(
PAYMENT_CALLBACK_MODE="mock",
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
+6
View File
@@ -3,6 +3,7 @@ from django.urls import path
from .views import (
AlipayRechargeCallbackView,
BalanceView,
ClientLatestReleaseView,
GenerateImageView,
GenerateTitleView,
ModelsView,
@@ -14,6 +15,11 @@ from .views import (
urlpatterns = [
path("v1/balance", BalanceView.as_view(), name="api-balance"),
path("v1/models", ModelsView.as_view(), name="api-models"),
path(
"v1/client/releases/latest",
ClientLatestReleaseView.as_view(),
name="api-client-release-latest",
),
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
+67 -1
View File
@@ -6,7 +6,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework.views import APIView
@@ -48,6 +48,7 @@ from apps.billing.services import (
get_balance_snapshot,
query_and_apply_recharge_payment,
)
from apps.portal.models import DownloadRelease
logger = logging.getLogger(__name__)
@@ -132,6 +133,71 @@ class ModelsView(ExternalApiView):
)
def _release_unpublished_response(platform: str) -> dict:
return {
"platform": platform,
"release": None,
"message": "暂未发布",
}
def _absolute_download_url(request, release: DownloadRelease) -> str:
download_url = release.download_url
if not download_url:
return ""
if download_url.startswith(("http://", "https://")):
return download_url
return request.build_absolute_uri(download_url)
class ClientLatestReleaseView(APIView):
authentication_classes = ()
permission_classes = (AllowAny,)
def get(self, request):
platform = (
request.query_params.get("platform")
or DownloadRelease.Platform.WINDOWS
).strip().lower()
if platform not in DownloadRelease.Platform.values:
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
release = (
DownloadRelease.objects.filter(platform=platform, is_current=True)
.order_by("-created_at", "-id")
.first()
)
if release is None:
return Response(
_release_unpublished_response(platform),
status=status.HTTP_200_OK,
)
download_url = _absolute_download_url(request, release)
if not download_url:
return Response(
_release_unpublished_response(platform),
status=status.HTTP_200_OK,
)
return Response(
{
"platform": platform,
"release": {
"version": release.version,
"download_url": download_url,
"sha256": release.sha256,
"release_notes": release.release_notes,
"published_at": timezone.localtime(release.updated_at).isoformat(),
},
},
status=status.HTTP_200_OK,
)
class PortalSessionApiView(APIView):
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
+2 -2
View File
@@ -38,7 +38,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「公开首页 + 客户端下载入口」。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片生成真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
当前项目处于:**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「桌面端最新版本检查接口」。生产侧仍需补真实支付回调到账闭环、配置并发布客户端下载包和图片生成真实耗时验证;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
优先路径:
@@ -48,7 +48,7 @@
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口。下一步需先确认业务优先级或拆新任务。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口。下一步需先确认业务优先级或拆新任务。
## 领取任务规则
+2 -1
View File
@@ -17,7 +17,7 @@
- **注册用户(终端用户)**:在用户端自助注册登录、扫码充值、查看自己的充值记录/充值总额/剩余点数/消费记录、自助生成与删除 API Key。点数余额挂在用户账户上。
- **接入方(用户的程序)**:持有该用户名下的 API Key,调用生成接口、查询余额;扣的是所属用户的点数。
- **运营人员(后台管理员)**:登录 django-admin,管理注册用户、启用/禁用账号、配置计费规则与汇率、查看充值订单/点数流水/调用记录、必要时手工调整点数。
- **游客 / 未登录 / 未携带有效 API Key**:用户端页面需登录访问;生成/余额接口未带有效 Key 一律拒绝(401)。
- **游客 / 未登录 / 未携带有效 API Key**:可访问公开首页、注册/登录页和公开客户端下载版本检查接口;其他用户端自助页面需登录访问;生成/余额接口未带有效 Key 一律拒绝(401)。
## 三、功能清单
@@ -37,6 +37,7 @@
| 点数流水 | 每次点数变动(充值/消费/退款/运营调整)落库,可对账 | P0 |
| 运营后台 | django-admin 管理用户、点数、计费规则、充值订单、流水、调用记录 | P0 |
| 公开首页 + 客户端下载 | 匿名访客打开域名看到项目介绍与上手引导(注册→充值→建 Key→下载客户端)、并能下载桌面端安装包(含版本/SHA256 校验);前期安装包托管在本服务器,后续可切对象存储/CDN | P1 |
| 桌面端版本检查 API | 桌面端可匿名请求最新版本 JSON,拿到版本号、下载地址、SHA256 和发布说明;无当前版本时返回“暂未发布”而不是报错 | P1 |
### 后续迭代
+4 -4
View File
@@ -18,9 +18,9 @@
组件落位:
- **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/消费记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;注册成功通过 allauth adapter 创建 0 点 `UserWallet`,不写赠点流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单与消费流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/消费记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明。
- **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/消费记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;注册成功通过 allauth adapter 创建 0 点 `UserWallet`,不写赠点流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单与消费流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/消费记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607 已新增公开 JSON 版本检查接口给桌面端自动更新使用。
- **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。
- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单。入口 `apps/api/`。生成/余额这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签。
- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点。
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
- **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。
- **内容安全层(本地敏感词 / 后续云审核)**:入口 `apps/moderation/`。T-604 只做 prompt 文本本地敏感词快筛,命中在扣点和调上游前返回 `content_blocked`;云内容安全、图片审核和输出审核保留扩展点,不在 T-604 范围。
@@ -49,7 +49,7 @@ T-305 已实现 `/api/v1/recharge/create` 与 `/api/v1/recharge/status`:两个
T-504 已实现用户端 `/recharge` 页面:GET 展示当前余额、充值表单、当前订单和最近充值;POST 经 `RechargeCreateForm` 校验金额与支付方式后复用 `create_recharge_order()` 创建 pending 订单并重定向到当前订单页,避免刷新重复下单;页面用本地 static 自托管的 qrcode.js 渲染 `code_url`,同时保留可复制支付票据兜底;浏览器每秒轮询 `/api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接写 `UserWallet.points_balance` 或 `PointsLedger`。当 `PAYMENT_CALLBACK_MODE=mock` 时,页面必须醒目提示二维码为测试票据,不能用于微信/支付宝真实支付。当前因支付宝可信 IP 未配置,用户端表单暂只开放微信支付,底层支付宝 API / 回调 / SDK 路径保留。
T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前校验 `image_url` 协议与解析后的 IP,只允许公网 `http` / `https`,拒绝私有、回环、链路本地、保留、组播、未指定地址;重定向由服务端手动跟随并逐跳重新校验,响应按 `IMAGE_URL_MAX_BYTES` 流式限长读取。`REST_FRAMEWORK` 全局默认认证为空、默认权限为 `IsAuthenticated`,外部 API 和用户端 session API 必须显式声明认证类;生成接口挂 `GenerateRateThrottle`,认证失败挂 IP 限流;充值下单通过 `RECHARGE_MAX_AMOUNT_CNY` 控制单笔上限。
T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前校验 `image_url` 协议与解析后的 IP,只允许公网 `http` / `https`,拒绝私有、回环、链路本地、保留、组播、未指定地址;重定向由服务端手动跟随并逐跳重新校验,响应按 `IMAGE_URL_MAX_BYTES` 流式限长读取。`REST_FRAMEWORK` 全局默认认证为空、默认权限为 `IsAuthenticated`,外部 API 和用户端 session API 必须显式声明认证类;生成接口挂 `GenerateRateThrottle`,认证失败挂 IP 限流;充值下单通过 `RECHARGE_MAX_AMOUNT_CNY` 控制单笔上限。T-607 版本检查接口已显式使用 `AllowAny` / 空认证,且只返回公开发布元数据。
**计费层(`apps/billing`)**
@@ -201,7 +201,7 @@ CREATE TABLE download_release (
);
```
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。T-606 已实现 `DownloadRelease`,admin 可上传安装包或填写 `external_url` 并标记当前版本;`external_url` 优先于 `file.url`,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。
T-102 已实现 `AiModel` / `ModelAlias` 的 Django models、admin、迁移与别名解析。T-103 已补 `AiConfigAuditLog`,admin 里保存/删除模型配置或能力别名时自动写审计日志。T-202 已实现 `PricingRule` / `ExchangeRate` 与 `apps.billing.pricing` 计算函数:定价按 `operation_type + alias + resolution` 查 active 规则,优先 exact resolution,再回退到空 resolution 默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`。T-203 已实现 `apps.billing.services`:`precharge_call()` 锁 `UserWallet` 行预扣并写 pending 调用与 consume 流水;`mark_call_success()` 确认成功不再改余额;`refund_call_points()` 锁调用记录并幂等退点,写 refund 流水。T-401 已在同一计费层新增 `adjust_wallet_points()`,供 admin 手工调点使用。T-606 已实现 `DownloadRelease`,admin 可上传安装包或填写 `external_url` 并标记当前版本;`external_url` 优先于 `file.url`,每平台仅一个当前版本由模型 `save()` 在事务内把同平台旧 current 置为 false。T-607 版本检查接口只读取 `DownloadRelease(platform, is_current=True)`,不新增表;响应 URL 是外部可访问 URL,不暴露本地 `MEDIA_ROOT`。默认别名唯一性由 model validation、admin 与导入器保证;MySQL 不支持通用 partial unique index,若后续要强制数据库层默认别名唯一,可另评估触发器或约束表。
> 可选增强(接口预留、MVP 不实现):`account_alias_permission`(按账号授权可用别名,防止调用方点用未授权/昂贵模型);别名按比例分流到多个模型(灰度/AB/故障转移)。适配器接口需为此留口子。
+1
View File
@@ -85,6 +85,7 @@
| T-605 | 落实「免邮箱验证」策略 | T-501 | 把 `ACCOUNT_EMAIL_VERIFICATION="none"`(注册即可用、不发验证邮件、邮箱仍必填且唯一)作为**既定策略**清理落地:① `config/settings.py` 把 `ACCOUNT_EMAIL_VERIFICATION = "none"#"mandatory"` 改为干净的 `"none"`(去行内注释),并把免验证下无实际意义的 `ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION` 设为 `False`;② 更新 `apps/portal/tests.py` 里假设 mandatory 的 2 条测试(signup 不再依赖验证邮件、未验证也可直接登录),改为断言「注册后可直接登录」;③ 同步全项目文档口径(`02-requirements`/`05-coding-rules`/`api`/`04-architecture`/`03-tech-stack`/`routes`/`env`/`deployment`/`00-ai-start-here` 中「邮箱验证」→「免邮箱验证,邮箱仍唯一」)。验收:`check` 通过,`apps.portal` 26 tests OK;全量 `manage.py test` 已尝试,跑到 95/131 后因远程 MySQL 连接超时失败(WinError 10051/10060),非本任务断言失败,详见 `../progress.md` | DONE |
| T-604 | 中文敏感词本地过滤(本地 keyword provider) | T-302, T-401 | 按 [`moderation.md`](moderation.md) 实施。**范围收紧**:T-604 只做输入 prompt 的本地敏感词快筛,不做云内容安全、不做输出审核、不做图片审核。**关键时序**:serializer 后先审 prompt,命中即 `400 content_blocked`;不得先下载 `image_url`,不得预扣点,不写 `CallRecord` / `PointsLedger`,不调上游。**核心实现**:新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线、Aho-Corasick matcher;`ahocorapy` 作为候选依赖,编码前必须验证 PyPI 可用性和 API 形状。**缓存**:matcher 进程内缓存,词库变更用共享 cache 版本号失效,不能只靠 `post_save` signal;生产依赖共享 cache。**配置**:`MODERATION_ENABLED=false` 默认 no-op;启用时 `MODERATION_PROVIDER=keyword`;MVP `SensitiveWord.action` 只支持 `block`。**验收**:`check`/`test` 全绿,覆盖 no-op、命中拦截且不扣点/不建记录/不调上游/不下载图片、归一化防绕过、词库变更后 matcher 重建;真实词库数据不进仓库,`__pycache__` 不进 Git | DONE |
| T-606 | 公开首页 + 客户端下载入口 | T-501 | 给网站补「前门」并提供桌面端下载。**路由改造**:`/` 从「重定向到 `/dashboard`」改为**公开首页**(匿名可访问、不跳登录);已登录用户显示「进入控制台」,匿名显示「注册/登录 + 下载客户端」。**首页内容**(SSR 模板):项目一句话介绍(生成标题/图片、按点数计费)+ 三步上手(注册→充值→建 API Key→桌面端填 Key)+ 下载入口 + 文档链接。**`DownloadRelease` 模型**:`platform`(如 windows)、`version`、`file`(FileField,**前期存 `MEDIA_ROOT`、服务器托管**) + `external_url`(URLField 可选,**后续切对象存储/CDN 用,有则优先**)、`sha256`、`is_current`(每平台仅一个当前版本)、`release_notes`、时间戳;admin 可上传安装包并标记当前版本;迁移。**下载区块**:展示当前 release 的版本、下载按钮、**SHA256 校验值**、可选 release notes;无 current release 时优雅提示「暂未发布」。**托管策略**:**前期安装包放本服务器**(生产由 **Nginx 直接服务 media/下载文件、不走 Django**,与 T-403 static/media serving 一致,大文件不占 gunicorn worker);`external_url` 预留,后续切对象存储只改后台链接不改代码。**安全**:下载走 HTTPS,页面展示 SHA256 供校验;**代码签名**作为决策登记——未签名 Windows 安装包会被 SmartScreen 拦「未知发布者」、macOS 被 Gatekeeper 拦,首页先给「如何忽略警告」说明,正式签名后续补(挂 Backlog / deployment)。**含测试**:`/` 匿名 200 不跳登录、下载区展示当前 release、无 current release 优雅处理、已登录用户显示「进入控制台」。**视觉原型(已定 v1)**:按 `prototypes/cmhub-homepage-v1.svg` 落地——「生成台」方向:靛蓝=生成 / 琥珀=点数;Hero 为「商品图 + 一句话 → 吸睛标题 + 生成主图 + 点数计量」转化图;四步上手 01–04、两张能力卡、深色计费 band、下载区(版本/SHA256/未签名提示);配色、间距、结构照此原型转成 Django 模板(Bootstrap + 本地 static)。**视觉一致性(brand token)**:按 [`brand.md`](brand.md) 抽出共享 CSS 变量(如 `apps/portal/static/portal/brand.css`),**首页与现有 portal 页面(dashboard/记录/充值/API Key)一起套用同一套 token**(把配色/字体变量灌进现有 Bootstrap,各页只引用变量不散写 hex),确保落地页与登录后控制台风格一致——**不是只做漂亮首页**。验收 `check`/`test`/`init` 全绿并在 `../progress.md` 留证据 | DONE |
| T-607 | 桌面端最新版本检查接口 | T-606 | 给桌面端自动检查更新提供只读 JSON 合约。**新增路由**:`GET /api/v1/client/releases/latest?platform=windows`,公开匿名可访问,**不需要 API Key、不读取用户、不扣点、不占用生成接口限流**;只返回发布元数据,不返回本地文件系统路径、后台 ID、内部状态或任何用户数据。**请求参数**:`platform` 支持 `windows` / `macos` / `linux`,缺省按 `windows`;非法平台返回 `400 bad_request`。**响应结构**:有当前版本时返回 `{platform, release:{version, download_url, sha256, release_notes, published_at}}`;无当前版本或当前版本没有下载地址时返回 `{platform, release:null, message:"暂未发布"}` 且 HTTP 200,方便客户端安静处理。**数据来源**:复用 `DownloadRelease`,只查 `platform + is_current=True`;`download_url` 继续按 `external_url` 优先,否则由 `file.url` 生成绝对 HTTPS URL;`published_at` 可先使用 `updated_at`。**缓存与安全**:可加短 TTL 公共缓存(如 60 秒);生产下载仍走 HTTPS + SHA256 校验;不得把 `MEDIA_ROOT` 或服务器路径暴露给客户端。**含测试**:匿名无 Key 可访问;当前 release 返回完整结构和绝对下载 URL;无 current release 返回 `release:null`;非法 platform 返回 400;`external_url` 优先于 `file`;响应不含本地路径、模型/密钥/用户字段;`check`/目标测试通过并在 `../progress.md` 留证据 | DONE |
## 里程碑
+46
View File
@@ -12,6 +12,7 @@
- 未携带有效 API Key 调用生成/余额接口:返回 `401`。
- 用户或 Key 被禁用/吊销(disabled/revoked):返回 `403`。
- **对外 API 只接受 API Key 认证,不接受 Web session**(浏览器带 cookie 也不能调 API,防绕过计费归属)。
- 例外:客户端下载版本检查接口只返回公开发布元数据,设计为匿名只读接口,不需要 API Key,不读取用户、不扣点。
- 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。
- 用户端注册使用同一个 `User` 账本主体;注册邮箱**必填且唯一**(`ACCOUNT_EMAIL_VERIFICATION="none"`,**不做邮箱验证**、注册即可用),唯一约束避免同邮箱对应多个点数账户。
- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
@@ -43,6 +44,8 @@ T-504/T-505 已实现用户端充值页基线:`/recharge` 走 Django session +
T-606 已实现公开首页与客户端下载入口:`GET /` 匿名返回 200,不再重定向到 `/dashboard`;匿名用户看到注册 / 登录 / 下载入口,登录用户看到「进入控制台」。下载区读取 `DownloadRelease(platform=windows, is_current=True)`,展示版本、下载按钮、SHA256 和发布说明;`external_url` 优先于后台上传文件的 `file.url`。`DownloadRelease` 不新增对外 JSON API,只由 SSR 首页和 django-admin 使用;生产本地安装包由 Nginx 直接服务 `MEDIA_ROOT/downloads/`,避免大文件占用 Gunicorn worker。
T-607 已实现 `GET /api/v1/client/releases/latest?platform=windows`,给桌面端自动检查更新使用。该接口公开匿名可访问,不需要 API Key,不读取用户、不扣点、不占用生成接口限流;只返回 `DownloadRelease` 的公开发布元数据。
通用错误响应:
```json
@@ -79,6 +82,49 @@ T-606 已实现公开首页与客户端下载入口:`GET /` 匿名返回 200
> **重要约定**:`model` 字段传的是**能力别名**(如 `title-standard` / `image-hd`),不是具体供应商模型名。后台把别名映射到当前的具体模型,换供应商时调用方零改动。供应商特有参数放 `parameters`,但只允许 Provider 白名单内的安全参数透传;`model`、`n`、`size`、`resolution`、`messages`、`image*` 等核心/计费字段由服务端固定,调用方传入时忽略。
### `GET /api/v1/client/releases/latest`
桌面端检查最新客户端版本。该接口为公开只读接口,不需要 API Key,不关联用户账本。
请求:
```http
GET /api/v1/client/releases/latest?platform=windows
```
参数:
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `platform` | 否 | `windows` / `macos` / `linux`;缺省按 `windows`。非法值返回 `400 bad_request` |
有当前版本时响应:
```json
{
"platform": "windows",
"release": {
"version": "1.0.0",
"download_url": "https://cm.833729.com/media/downloads/cmhub-desktop-1.0.0.exe",
"sha256": "64位sha256",
"release_notes": "本次更新说明",
"published_at": "2026-07-06T18:00:00+08:00"
}
}
```
无当前版本或当前版本没有下载地址时响应:
```json
{
"platform": "windows",
"release": null,
"message": "暂未发布"
}
```
要点:接口只查 `DownloadRelease(platform, is_current=True)`;`download_url` 优先使用 `external_url`,否则用 `file.url` 生成绝对 HTTPS URL;`published_at` MVP 可使用 `DownloadRelease.updated_at`。响应不得包含本地 `MEDIA_ROOT`、文件系统路径、后台 ID、`is_current`、用户信息、API Key、模型配置或任何密钥字段。成功和“暂未发布”均返回 HTTP 200,方便桌面端静默检查;非法平台返回 `400 bad_request`。
### `POST /api/v1/generate/title`
生成标题。请求:
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -108,7 +108,7 @@
- Phase 5 已完成 T-401:运营后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录;手工调点必须填写原因,并经计费层锁钱包、写 `adjust` 流水。
- Phase 5 已完成 T-402:MVP P0 验收通过,注册/充值/API Key/调用/余额/记录/后台/别名映射均有测试证据,详见 `mvp-acceptance.md`。
- Phase 5 已完成 T-403:已补部署 / 运行文档,明确宝塔/Nginx/Gunicorn、生产静态与媒体文件、共享缓存限流、图片同步超时、真实商户配置和上线检查;当前免邮箱验证,邮件服务仅作为后续密码找回/通知等邮件能力配置项,详见 `deployment.md`。
- Phase 6 已完成 T-601 可用别名发现、T-602 后台表名/分组中文化、T-603 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地与 T-606 公开首页 + 客户端下载入口;线上真实标题生成已恢复并验证扣点。
- Phase 6 已完成 T-601 可用别名发现、T-602 后台表名/分组中文化、T-603 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口与 T-607 桌面端最新版本检查接口;线上真实标题生成已恢复并验证扣点。
- 下一步可继续补跑真实支付回调到账闭环、配置并发布客户端下载包和真实图片耗时验证。
---
+3
View File
@@ -28,11 +28,14 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 `
| `/api/v1/generate/image` | POST | 生成图片(同步) | API Key |
| `/api/v1/balance` | GET | 查询点数余额 | API Key |
| `/api/v1/models` | GET | 查询可调用能力别名、能力和点数单价 | API Key |
| `/api/v1/client/releases/latest` | GET | 桌面端检查最新客户端版本(公开发布元数据) | 公开 |
| `/api/v1/recharge/create` | POST | 用户端发起充值(weixin/alipay),下单取二维码 | Session(用户端) |
| `/api/v1/recharge/status` | GET | 轮询订单状态(前端每秒) | Session(用户端) |
| `/api/v1/recharge/callback/wechat` | POST | 微信 V3 异步回调 | 验签(`@csrf_exempt`) |
| `/api/v1/recharge/callback/alipay` | POST | 支付宝异步回调 | 验签(`@csrf_exempt`) |
T-607 已落地 `/api/v1/client/releases/latest`:公开匿名可访问,不需要 API Key,不读取用户账本,只返回当前 `DownloadRelease` 的版本、下载 URL、SHA256、发布说明和发布时间;无当前版本返回 `release:null`。
## 运营后台(django-admin,`/admin/`)
后台用 Django Session 登录,按模型注册 Admin:
+45
View File
@@ -1400,3 +1400,48 @@
- `.\init.ps1`:收尾验证通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。
- `py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests --keepdb --noinput --verbosity 2`:已尝试,因远程 MySQL `43.128.3.240` 在测试库迁移阶段连接超时(2003 / WinError 10060)中断,不是断言失败;数据库恢复后需补跑 portal 整组。
- 下一步:看板当前无新的编号任务;建议优先确认真实支付回调到账闭环、上传/发布 Windows 客户端安装包,或补真实图片生成耗时验证并拆成新任务。
## 2026-07-06 决策:T-607 桌面端最新版本检查接口
- 状态:DONE(文档创建任务,未改代码)。
- 背景:T-606 已有公开首页和 `DownloadRelease` 后台发布配置,但桌面端如果要自动检查更新,不能依赖解析 HTML,需要稳定 JSON 合约。
- 变更:
- `docs/06-tasks.md`:新增 T-607「桌面端最新版本检查接口」,依赖 T-606,状态 TODO。
- `docs/api.md`:新增 `GET /api/v1/client/releases/latest?platform=windows` 合约、响应示例和安全约束。
- `docs/routes.md`:登记计划中的公开版本检查 API。
- `docs/02-requirements.md`:把桌面端版本检查 API 加入 P1 功能清单。
- `docs/04-architecture.md`:同步公开只读例外、数据来源和不得暴露本地路径的约束。
- `docs/current-state.md`、`docs/00-ai-start-here.md`、`docs/project-brief.md`、`README.md`:同步下一项任务为 T-607。
- 决策:
- 版本检查接口公开匿名可访问,不需要 API Key,不读取用户、不扣点、不占用生成接口限流。
- 只返回 `DownloadRelease` 的公开发布元数据:`platform`、`version`、`download_url`、`sha256`、`release_notes`、`published_at`。
- `platform` 缺省为 `windows`,支持 `windows` / `macos` / `linux`;非法平台返回 `400 bad_request`。
- 无当前版本或当前版本没有下载地址时返回 HTTP 200,结构为 `release:null` + `message:"暂未发布"`,方便桌面端静默处理。
- `download_url` 优先使用 `external_url`,否则用上传文件 URL 生成外部可访问绝对 URL;响应不得暴露 `MEDIA_ROOT`、文件系统路径、后台 ID、用户信息、模型配置或密钥。
- 验证:文档修改;后续执行 T-607 时需补 `check`、目标 API 测试和 `git diff --check` 证据。
- 下一步:领取 T-607,按文档实现公开版本检查 API。
## 2026-07-06 T-607 桌面端最新版本检查接口
- 状态:DONE。
- 代码变更:
- `apps/api/views.py`:新增 `ClientLatestReleaseView`,显式空认证 + `AllowAny`,公开匿名读取 `DownloadRelease(platform, is_current=True)`,返回版本、下载地址、SHA256、发布说明和发布时间;无当前版本或无下载地址返回 `release:null`。
- `apps/api/urls.py`:新增 `GET /api/v1/client/releases/latest` 路由。
- `apps/api/tests.py`:新增 `ClientLatestReleaseApiTests`,覆盖匿名无 Key、Web session/无效 Authorization 不影响公开读取、当前版本结构、文件 URL 绝对化、`external_url` 优先、无 current、无下载地址、非法平台、macOS 平台和内部字段不泄露。
- 文档变更:
- `docs/06-tasks.md`:T-607 标为 DONE。
- `docs/api.md` / `docs/routes.md` / `docs/04-architecture.md`:从计划口径改为已实现口径。
- `docs/current-state.md`、`docs/00-ai-start-here.md`、`docs/project-brief.md`、`README.md`:同步 Phase 6 当前状态、当前 API 列表和下一步口径。
- 验证:
- `.\init.ps1`:开工前通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。
- `py -3.12 -m py_compile apps\api\views.py apps\api\urls.py apps\api\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
- `py -3.12 manage.py test apps.api.tests.ClientLatestReleaseApiTests --keepdb --noinput --verbosity 2`:通过,9 tests OK。
- `py -3.12 manage.py test apps.api.tests.ApiKeyAuthenticationTests apps.api.tests.BalanceApiTests apps.api.tests.ModelsCatalogApiTests apps.api.tests.ClientLatestReleaseApiTests --keepdb --noinput --verbosity 2`:通过,25 tests OK。
- `py -3.12 manage.py test apps.api --keepdb --noinput --verbosity 2`:首次运行 184 秒超时,没有拿到结论。
- `py -3.12 manage.py test apps.api --keepdb --noinput --verbosity 1`:重跑通过,54 tests OK;仅保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。
- `.\init.ps1`:收尾验证通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- 决策:T-607 不继承 `ExternalApiView`,避免要求 API Key;也不挂 `GenerateRateThrottle`,避免版本检查占用生成额度。文件下载地址按请求生成绝对 URL;生产 HTTPS 由已有 proxy/HTTPS 配置保证。
- 下一步:看板当前无新的编号任务;建议继续按业务优先级拆真实支付回调到账闭环、客户端下载包发布或真实图片耗时验证。