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:
1 parent
7e23d6fa84
commit
b419e0afde
30 files changed
+1876
-468
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,157 @@
|
||||
"""M7 验收测试(rewrite-plan M7 验收门)。
|
||||
|
||||
① 生成内容未定相不成为正式形仪(candidate 不进呈现位;定相才转正;主仪唯一);
|
||||
② 克隆授权可撤回(撤回立即生效,合成前置校验被拒;过期同理);
|
||||
③ 关闭全部多模态后文字相伴完整可用(ASR 503 降级时镜树/缘契链路照常——回归)。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
|
||||
yield c
|
||||
|
||||
|
||||
async def _character(client: AsyncClient) -> dict:
|
||||
resp = await client.get("/api/v1/characters/active")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── ① 未定相不成为正式形仪 ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_candidate_not_primary_until_adopted(client: AsyncClient) -> None:
|
||||
character = await _character(client)
|
||||
base = f"/api/v1/characters/{character['id']}"
|
||||
|
||||
# 登记两个主仪候选(生成来源)
|
||||
c1 = await client.post(
|
||||
f"{base}/forms",
|
||||
json={"kind": "primary", "source": "image_gen", "description": "立绘候选一"},
|
||||
)
|
||||
assert c1.status_code == 201, c1.text
|
||||
c2 = await client.post(
|
||||
f"{base}/forms",
|
||||
json={"kind": "primary", "source": "image_gen", "description": "立绘候选二"},
|
||||
)
|
||||
assert c2.status_code == 201
|
||||
|
||||
# 未定相:呈现位为空(candidate 不进正式形仪)
|
||||
primary = await client.get(f"{base}/forms/primary")
|
||||
assert primary.status_code == 200
|
||||
assert primary.json() is None
|
||||
|
||||
# 定相候选一 → 成为主仪
|
||||
adopt1 = await client.post(f"{base}/forms/{c1.json()['id']}/adopt")
|
||||
assert adopt1.status_code == 200
|
||||
assert adopt1.json()["status"] == "adopted"
|
||||
primary = await client.get(f"{base}/forms/primary")
|
||||
assert primary.json()["id"] == c1.json()["id"]
|
||||
|
||||
# 定相候选二 → 主仪唯一(旧主仪自动 retired)
|
||||
adopt2 = await client.post(f"{base}/forms/{c2.json()['id']}/adopt")
|
||||
assert adopt2.status_code == 200
|
||||
primary = await client.get(f"{base}/forms/primary")
|
||||
assert primary.json()["id"] == c2.json()["id"]
|
||||
forms = await client.get(f"{base}/forms")
|
||||
by_id = {f["id"]: f for f in forms.json()}
|
||||
assert by_id[c1.json()["id"]]["status"] == "retired"
|
||||
assert by_id[c2.json()["id"]]["status"] == "adopted"
|
||||
|
||||
# 废弃候选
|
||||
c3 = await client.post(
|
||||
f"{base}/forms", json={"kind": "costume", "source": "upload", "description": "化仪候选"}
|
||||
)
|
||||
discard = await client.post(f"{base}/forms/{c3.json()['id']}/discard")
|
||||
assert discard.json()["status"] == "discarded"
|
||||
|
||||
|
||||
# ── ② 克隆授权可撤回 ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_clone_grant_revocable(client: AsyncClient) -> None:
|
||||
character = await _character(client)
|
||||
base = f"/api/v1/characters/{character['id']}"
|
||||
|
||||
# 授权克隆必须声明用途与传播范围
|
||||
incomplete = await client.post(
|
||||
f"{base}/voices", json={"source": "authorized_clone", "name": "授权声线"}
|
||||
)
|
||||
assert incomplete.status_code == 422 or "用途" in incomplete.text
|
||||
|
||||
voice = await client.post(
|
||||
f"{base}/voices",
|
||||
json={
|
||||
"source": "authorized_clone",
|
||||
"name": "授权声线",
|
||||
"params": {"timbre": "warm"},
|
||||
"grant_purpose": "人物语音消息",
|
||||
"grant_distribution": "仅本镜域私用",
|
||||
},
|
||||
)
|
||||
assert voice.status_code == 201, voice.text
|
||||
voice_id = voice.json()["id"]
|
||||
|
||||
# 授权有效:合成前置校验通过
|
||||
usable = await client.post(f"{base}/voices/{voice_id}/assert-usable")
|
||||
assert usable.status_code == 200
|
||||
|
||||
# 撤回 → 立即生效
|
||||
revoked = await client.post(f"{base}/voices/{voice_id}/revoke-grant")
|
||||
assert revoked.status_code == 200
|
||||
assert revoked.json()["effective"] == "immediate"
|
||||
denied = await client.post(f"{base}/voices/{voice_id}/assert-usable")
|
||||
assert denied.status_code == 409 or denied.status_code == 403
|
||||
|
||||
# 合成预设不受授权约束
|
||||
preset = await client.post(
|
||||
f"{base}/voices", json={"source": "synthesis_preset", "name": "预设音色 A"}
|
||||
)
|
||||
assert preset.status_code == 201
|
||||
ok = await client.post(f"{base}/voices/{preset.json()['id']}/assert-usable")
|
||||
assert ok.status_code == 200
|
||||
|
||||
|
||||
# ── ③ 关闭全部多模态后文字相伴完整可用(回归) ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multimodal_off_text_companion_still_complete(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from app.domains.xingyi import asr as xingyi_asr
|
||||
|
||||
character = await _character(client)
|
||||
base = f"/api/v1/characters/{character['id']}"
|
||||
|
||||
# 多模态全关:元炁未配置 → ASR 503(不降级不伪造)
|
||||
monkeypatch.setattr(xingyi_asr.settings, "yuanqi_base_url", "")
|
||||
muted = await client.post(
|
||||
f"{base}/voice/transcriptions",
|
||||
files={"audio": ("a.webm", b"fake-bytes", "audio/webm")},
|
||||
)
|
||||
assert muted.status_code == 503
|
||||
|
||||
# 文字相伴链路照常:镜树问答(文字)+ 缘契(文字)
|
||||
tree = await client.post(
|
||||
"/api/v1/trees", json={"character_id": character["id"], "title": "纯文字回归"}
|
||||
)
|
||||
assert tree.status_code in (200, 201)
|
||||
cov = await client.post(
|
||||
"/api/v1/covenants",
|
||||
json={"character_id": character["id"], "dimensions": {"companionship": "text-only"}},
|
||||
)
|
||||
assert cov.status_code == 201
|
||||
caps = await client.get("/api/v1/capabilities")
|
||||
assert caps.status_code == 200
|
||||
Reference in new issue
Block a user