M7:形仪与语音——真形锚点、候选定相、声音档案与克隆授权、ASR 接入

- 形仪域 domains/xingyi:form_anchors(稳定辨识特征/年龄表现/许可禁忌/可变范围——生成内容不可静默覆盖锚点);forms(主仪/化仪/文字形仪,candidate→adopt 定相治理:未定相不进呈现位、主仪唯一、新主仪自动退役旧主仪);voice_profiles(三来源,授权克隆必填用途与传播范围,revoke-grant 撤回立即生效,assert-usable 合成前置校验拒绝撤回/过期)
- ASR:元炁 SenseVoice /v1/audio/transcriptions 透传(Whisper 兼容 multipart);YUANQI_BASE_URL 未配置时 503 不降级不伪造——多模态关闭纪律
- 前端形仪工坊(人物详情页):真形锚点编辑(特征逐行解析)/候选登记定相废弃/声音档案建立与授权撤回;头像呈现位改走定相主仪(forms/primary);xingyi api 库 + mock + 组件 4 用例
- 退役:旧 avatar 模块(services/endpoints/avatar、avatar_dir 配置、avatar_url 字段、前端直传组件与测试)
- 验收(test_m7_xingyi 三用例):未定相不成为正式形仪(含主仪唯一与旧主仪 retired 断言);克隆授权可撤回(缺用途拒/撤回即拒/过期同理/预设不受约束);关闭全部多模态后文字相伴完整可用(ASR 503 时镜树缘契百工照常——回归)
- 四套件:后端 376、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 008 净库+±1 干净
This commit is contained in:
chenxin committed 2026-08-27 20:26:52 +08:00
1 parent 7e23d6fa84
commit b419e0afde
30 files changed
+1876 -468

No files matched your search

-161
View File
@@ -1,161 +0,0 @@
"""角色头像上传 API 测试。
验证头像文件的上传、读取、替换、类型校验、大小限制与清理。
"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
_AVATAR_PATH = "/api/v1/characters"
@pytest.fixture
async def admin_client(
admin_headers: dict[str, str],
) -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
headers=admin_headers,
) as http_client:
yield http_client
@pytest.fixture
async def active_char_id(admin_client: AsyncClient) -> str:
"""获取当前激活角色 ID。"""
resp = await admin_client.get(f"{_AVATAR_PATH}/active")
assert resp.status_code == 200, resp.text
return resp.json()["id"]
@pytest.mark.asyncio
async def test_upload_and_get_avatar(
admin_client: AsyncClient, active_char_id: str,
) -> None:
"""上传头像后可以 GET 获取到图片文件。"""
png_bytes = _make_test_png()
upload = await admin_client.post(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
files={"file": ("test.png", png_bytes, "image/png")},
)
assert upload.status_code == 200, upload.text
body = upload.json()
assert "avatar_url" in body
assert active_char_id in body["avatar_url"]
# GET 获取图片
get_resp = await admin_client.get(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
)
assert get_resp.status_code == 200
assert get_resp.headers["content-type"] == "image/png"
assert len(await get_resp.aread()) > 0
@pytest.mark.asyncio
async def test_avatar_replace(
admin_client: AsyncClient, active_char_id: str,
) -> None:
"""上传新头像会替换旧头像(同角色只保留一张)。"""
# 上传第一张
first = _make_test_png(width=10, height=10)
r1 = await admin_client.post(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
files={"file": ("first.png", first, "image/png")},
)
assert r1.status_code == 200
# 上传第二张
second = _make_test_png(width=20, height=20)
r2 = await admin_client.post(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
files={"file": ("second.png", second, "image/png")},
)
assert r2.status_code == 200
# 读取头像文件大小应与第二张匹配
get_resp = await admin_client.get(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
)
assert get_resp.status_code == 200
body = await get_resp.aread()
assert len(body) == len(second)
@pytest.mark.asyncio
async def test_avatar_rejects_invalid_type(
admin_client: AsyncClient, active_char_id: str,
) -> None:
"""不支持的文件类型应返回 4xx。"""
resp = await admin_client.post(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
files={"file": ("test.svg", b"<svg></svg>", "image/svg+xml")},
)
assert resp.status_code in (400, 422)
@pytest.mark.asyncio
async def test_avatar_requires_auth(active_char_id: str) -> None:
"""未认证用户不能上传头像。"""
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport,
base_url="http://test",
) as client:
resp = await client.post(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
files={"file": ("test.png", _make_test_png(), "image/png")},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_avatar_not_found(admin_client: AsyncClient) -> None:
"""不存在的角色上传头像应 404。"""
resp = await admin_client.post(
f"{_AVATAR_PATH}/character_nonexistent_id_00000/avatar",
files={"file": ("test.png", _make_test_png(), "image/png")},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_avatar_no_image(
admin_client: AsyncClient, active_char_id: str,
) -> None:
"""无头像时 GET 应 404。"""
from app.services.avatar import delete_avatar
await delete_avatar(active_char_id)
resp = await admin_client.get(
f"{_AVATAR_PATH}/{active_char_id}/avatar",
)
assert resp.status_code == 404
# ── 辅助:生成最小 PNG ──
def _make_test_png(width: int = 1, height: int = 1) -> bytes:
"""生成一个最小的有效 PNG 文件。"""
import struct
import zlib
sig = b"\x89PNG\r\n\x1a\n"
ihdr_data = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
ihdr_crc = zlib.crc32(b"IHDR" + ihdr_data)
ihdr = b"\x00\x00\x00\x0dIHDR" + ihdr_data + struct.pack(">I", ihdr_crc)
raw = b"\x00" + b"\xff\x00\x00" * width
raw *= height
compressed = zlib.compress(raw)
idat_crc = zlib.crc32(b"IDAT" + compressed)
idat = struct.pack(">I", len(compressed)) + b"IDAT" + compressed + struct.pack(">I", idat_crc)
iend_crc = zlib.crc32(b"IEND")
iend = b"\x00\x00\x00\x00IEND" + struct.pack(">I", iend_crc)
return sig + ihdr + idat + iend