feat: add resource library and real image workflow

This commit is contained in:
afei A
2026-03-29 00:24:29 +08:00
parent eeaff269eb
commit 04da401ab4
38 changed files with 3033 additions and 117 deletions

View File

@@ -39,15 +39,15 @@ async def wait_for_step_count(client, order_id: int, step_name: str, minimum_cou
async def create_mid_end_order(client):
"""Create a standard semi-pro order for review-path tests."""
resources = await create_workflow_resources(client, include_scene=True)
response = await client.post(
"/api/v1/orders",
json={
"customer_level": "mid",
"service_mode": "semi_pro",
"model_id": 101,
"pose_id": 3,
"garment_asset_id": 9001,
"scene_ref_asset_id": 8001,
"model_id": resources["model"]["id"],
"garment_asset_id": resources["garment"]["id"],
"scene_ref_asset_id": resources["scene"]["id"],
},
)
@@ -55,6 +55,115 @@ async def create_mid_end_order(client):
return response.json()
async def create_library_resource(client, payload: dict) -> dict:
"""Create a resource-library item for workflow integration tests."""
response = await client.post("/api/v1/library/resources", json=payload)
assert response.status_code == 201
return response.json()
async def create_workflow_resources(client, *, include_scene: bool) -> dict[str, dict]:
"""Create a minimal set of real library resources for workflow tests."""
model_resource = await create_library_resource(
client,
{
"resource_type": "model",
"name": "Ava Studio",
"description": "棚拍女模特",
"tags": ["女装", "棚拍"],
"gender": "female",
"age_group": "adult",
"files": [
{
"file_role": "original",
"storage_key": "library/models/ava/original.png",
"public_url": "https://images.example.com/library/models/ava/original.png",
"mime_type": "image/png",
"size_bytes": 1024,
"sort_order": 0,
"width": 1200,
"height": 1600,
},
{
"file_role": "thumbnail",
"storage_key": "library/models/ava/thumb.png",
"public_url": "https://images.example.com/library/models/ava/thumb.png",
"mime_type": "image/png",
"size_bytes": 256,
"sort_order": 0,
},
],
},
)
garment_resource = await create_library_resource(
client,
{
"resource_type": "garment",
"name": "Cream Dress",
"description": "米白色连衣裙",
"tags": ["女装"],
"category": "dress",
"files": [
{
"file_role": "original",
"storage_key": "library/garments/cream-dress/original.png",
"public_url": "https://images.example.com/library/garments/cream-dress/original.png",
"mime_type": "image/png",
"size_bytes": 1024,
"sort_order": 0,
},
{
"file_role": "thumbnail",
"storage_key": "library/garments/cream-dress/thumb.png",
"public_url": "https://images.example.com/library/garments/cream-dress/thumb.png",
"mime_type": "image/png",
"size_bytes": 256,
"sort_order": 0,
},
],
},
)
resources = {
"model": model_resource,
"garment": garment_resource,
}
if include_scene:
resources["scene"] = await create_library_resource(
client,
{
"resource_type": "scene",
"name": "Loft Window",
"description": "暖调室内场景",
"tags": ["室内"],
"environment": "indoor",
"files": [
{
"file_role": "original",
"storage_key": "library/scenes/loft/original.png",
"public_url": "https://images.example.com/library/scenes/loft/original.png",
"mime_type": "image/png",
"size_bytes": 1024,
"sort_order": 0,
},
{
"file_role": "thumbnail",
"storage_key": "library/scenes/loft/thumb.png",
"public_url": "https://images.example.com/library/scenes/loft/thumb.png",
"mime_type": "image/png",
"size_bytes": 256,
"sort_order": 0,
},
],
},
)
return resources
@pytest.mark.asyncio
async def test_healthcheck(api_runtime):
"""The health endpoint should always respond successfully."""
@@ -71,15 +180,15 @@ async def test_low_end_order_completes(api_runtime):
"""Low-end orders should run through the full automated pipeline."""
client, env = api_runtime
resources = await create_workflow_resources(client, include_scene=True)
response = await client.post(
"/api/v1/orders",
json={
"customer_level": "low",
"service_mode": "auto_basic",
"model_id": 101,
"pose_id": 3,
"garment_asset_id": 9001,
"scene_ref_asset_id": 8001,
"model_id": resources["model"]["id"],
"garment_asset_id": resources["garment"]["id"],
"scene_ref_asset_id": resources["scene"]["id"],
},
)
@@ -105,6 +214,160 @@ async def test_low_end_order_completes(api_runtime):
assert workflow_response.json()["workflow_status"] == "succeeded"
@pytest.mark.asyncio
async def test_prepare_model_step_uses_library_original_file(api_runtime):
"""prepare_model should use the model resource's original file rather than a mock URI."""
def expected_input_snapshot(resource: dict) -> dict:
original_file = next(file for file in resource["files"] if file["file_role"] == "original")
snapshot = {
"resource_id": resource["id"],
"resource_name": resource["name"],
"original_file_id": original_file["id"],
"original_url": resource["original_url"],
"mime_type": original_file["mime_type"],
"width": original_file["width"],
"height": original_file["height"],
}
return {key: value for key, value in snapshot.items() if value is not None}
client, env = api_runtime
resources = await create_workflow_resources(client, include_scene=True)
model_resource = resources["model"]
garment_resource = resources["garment"]
scene_resource = resources["scene"]
response = await client.post(
"/api/v1/orders",
json={
"customer_level": "low",
"service_mode": "auto_basic",
"model_id": model_resource["id"],
"garment_asset_id": garment_resource["id"],
"scene_ref_asset_id": scene_resource["id"],
},
)
assert response.status_code == 201
payload = response.json()
handle = env.client.get_workflow_handle(payload["workflow_id"])
result = await handle.result()
assert result["status"] == "succeeded"
assets_response = await client.get(f"/api/v1/orders/{payload['order_id']}/assets")
assert assets_response.status_code == 200
prepared_asset = next(
asset for asset in assets_response.json() if asset["asset_type"] == "prepared_model"
)
assert prepared_asset["uri"] == model_resource["original_url"]
assert prepared_asset["metadata_json"]["library_resource_id"] == model_resource["id"]
assert prepared_asset["metadata_json"]["library_original_file_id"] == next(
file["id"] for file in model_resource["files"] if file["file_role"] == "original"
)
assert prepared_asset["metadata_json"]["library_original_url"] == model_resource["original_url"]
assert prepared_asset["metadata_json"]["model_input"] == expected_input_snapshot(model_resource)
assert prepared_asset["metadata_json"]["garment_input"] == expected_input_snapshot(garment_resource)
assert prepared_asset["metadata_json"]["scene_input"] == expected_input_snapshot(scene_resource)
@pytest.mark.asyncio
async def test_low_end_order_completes_without_scene(api_runtime):
"""Low-end orders should still complete when scene input is omitted."""
client, env = api_runtime
resources = await create_workflow_resources(client, include_scene=False)
response = await client.post(
"/api/v1/orders",
json={
"customer_level": "low",
"service_mode": "auto_basic",
"model_id": resources["model"]["id"],
"garment_asset_id": resources["garment"]["id"],
},
)
assert response.status_code == 201
payload = response.json()
handle = env.client.get_workflow_handle(payload["workflow_id"])
result = await handle.result()
assert result["status"] == "succeeded"
order_response = await client.get(f"/api/v1/orders/{payload['order_id']}")
assert order_response.status_code == 200
assert order_response.json()["scene_ref_asset_id"] is None
assert order_response.json()["status"] == "succeeded"
@pytest.mark.asyncio
async def test_low_end_order_reuses_tryon_uri_for_qc_and_final_without_scene(api_runtime):
"""Without scene input, qc/export should keep the real try-on image URI."""
client, env = api_runtime
resources = await create_workflow_resources(client, include_scene=False)
response = await client.post(
"/api/v1/orders",
json={
"customer_level": "low",
"service_mode": "auto_basic",
"model_id": resources["model"]["id"],
"garment_asset_id": resources["garment"]["id"],
},
)
assert response.status_code == 201
payload = response.json()
handle = env.client.get_workflow_handle(payload["workflow_id"])
result = await handle.result()
assert result["status"] == "succeeded"
assets_response = await client.get(f"/api/v1/orders/{payload['order_id']}/assets")
assert assets_response.status_code == 200
assets = assets_response.json()
tryon_asset = next(asset for asset in assets if asset["asset_type"] == "tryon")
qc_asset = next(asset for asset in assets if asset["asset_type"] == "qc_candidate")
final_asset = next(asset for asset in assets if asset["asset_type"] == "final")
assert qc_asset["uri"] == tryon_asset["uri"]
assert final_asset["uri"] == tryon_asset["uri"]
assert final_asset["metadata_json"]["source_asset_id"] == qc_asset["id"]
@pytest.mark.asyncio
async def test_mid_end_order_waits_review_then_approves_without_scene(api_runtime):
"""Mid-end orders should still reach review and approve when scene input is omitted."""
client, env = api_runtime
resources = await create_workflow_resources(client, include_scene=False)
response = await client.post(
"/api/v1/orders",
json={
"customer_level": "mid",
"service_mode": "semi_pro",
"model_id": resources["model"]["id"],
"garment_asset_id": resources["garment"]["id"],
},
)
assert response.status_code == 201
payload = response.json()
await wait_for_workflow_status(client, payload["order_id"], "waiting_review")
review_response = await client.post(
f"/api/v1/reviews/{payload['order_id']}/submit",
json={"decision": "approve", "reviewer_id": 77, "comment": "通过"},
)
assert review_response.status_code == 200
handle = env.client.get_workflow_handle(payload["workflow_id"])
result = await handle.result()
assert result["status"] == "succeeded"
@pytest.mark.asyncio
async def test_mid_end_order_waits_review_then_approves(api_runtime):
"""Mid-end orders should pause for review and continue after approval."""
@@ -133,6 +396,162 @@ async def test_mid_end_order_waits_review_then_approves(api_runtime):
assert order_response.json()["status"] == "succeeded"
@pytest.mark.asyncio
async def test_library_upload_presign_returns_direct_upload_metadata(api_runtime):
"""Library upload presign should return S3 direct-upload metadata and a public URL."""
client, _ = api_runtime
response = await client.post(
"/api/v1/library/uploads/presign",
json={
"resource_type": "model",
"file_role": "original",
"file_name": "ava.png",
"content_type": "image/png",
},
)
assert response.status_code == 200
payload = response.json()
assert payload["method"] == "PUT"
assert payload["storage_key"].startswith("library/models/")
assert payload["public_url"].startswith("https://")
assert "library/models/" in payload["upload_url"]
@pytest.mark.asyncio
async def test_library_resource_can_be_created_and_listed(api_runtime):
"""Creating a library resource should persist original, thumbnail, and gallery files."""
client, _ = api_runtime
create_response = await client.post(
"/api/v1/library/resources",
json={
"resource_type": "model",
"name": "Ava Studio",
"description": "棚拍女模特",
"tags": ["女装", "棚拍"],
"gender": "female",
"age_group": "adult",
"files": [
{
"file_role": "original",
"storage_key": "library/models/ava/original.png",
"public_url": "https://images.marcusd.me/library/models/ava/original.png",
"mime_type": "image/png",
"size_bytes": 1024,
"sort_order": 0,
},
{
"file_role": "thumbnail",
"storage_key": "library/models/ava/thumb.png",
"public_url": "https://images.marcusd.me/library/models/ava/thumb.png",
"mime_type": "image/png",
"size_bytes": 256,
"sort_order": 0,
},
{
"file_role": "gallery",
"storage_key": "library/models/ava/gallery-1.png",
"public_url": "https://images.marcusd.me/library/models/ava/gallery-1.png",
"mime_type": "image/png",
"size_bytes": 2048,
"sort_order": 1,
},
],
},
)
assert create_response.status_code == 201
created = create_response.json()
assert created["resource_type"] == "model"
assert created["pose_id"] is None
assert created["cover_url"] == "https://images.marcusd.me/library/models/ava/thumb.png"
assert created["original_url"] == "https://images.marcusd.me/library/models/ava/original.png"
assert len(created["files"]) == 3
list_response = await client.get("/api/v1/library/resources", params={"resource_type": "model"})
assert list_response.status_code == 200
listing = list_response.json()
assert listing["total"] == 1
assert listing["items"][0]["name"] == "Ava Studio"
assert listing["items"][0]["gender"] == "female"
assert listing["items"][0]["pose_id"] is None
@pytest.mark.asyncio
async def test_library_resource_list_supports_type_specific_filters(api_runtime):
"""Library listing should support resource-type-specific filter fields."""
client, _ = api_runtime
payloads = [
{
"resource_type": "scene",
"name": "Loft Window",
"description": "暖调室内场景",
"tags": ["室内"],
"environment": "indoor",
"files": [
{
"file_role": "original",
"storage_key": "library/scenes/loft/original.png",
"public_url": "https://images.marcusd.me/library/scenes/loft/original.png",
"mime_type": "image/png",
"size_bytes": 1024,
"sort_order": 0,
},
{
"file_role": "thumbnail",
"storage_key": "library/scenes/loft/thumb.png",
"public_url": "https://images.marcusd.me/library/scenes/loft/thumb.png",
"mime_type": "image/png",
"size_bytes": 256,
"sort_order": 0,
},
],
},
{
"resource_type": "scene",
"name": "Garden Walk",
"description": "自然光室外场景",
"tags": ["室外"],
"environment": "outdoor",
"files": [
{
"file_role": "original",
"storage_key": "library/scenes/garden/original.png",
"public_url": "https://images.marcusd.me/library/scenes/garden/original.png",
"mime_type": "image/png",
"size_bytes": 1024,
"sort_order": 0,
},
{
"file_role": "thumbnail",
"storage_key": "library/scenes/garden/thumb.png",
"public_url": "https://images.marcusd.me/library/scenes/garden/thumb.png",
"mime_type": "image/png",
"size_bytes": 256,
"sort_order": 0,
},
],
},
]
for payload in payloads:
response = await client.post("/api/v1/library/resources", json=payload)
assert response.status_code == 201
indoor_response = await client.get(
"/api/v1/library/resources",
params={"resource_type": "scene", "environment": "indoor"},
)
assert indoor_response.status_code == 200
indoor_payload = indoor_response.json()
assert indoor_payload["total"] == 1
assert indoor_payload["items"][0]["name"] == "Loft Window"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("decision", "expected_step"),
@@ -312,15 +731,16 @@ async def test_orders_list_returns_recent_orders_with_revision_summary(api_runti
client, env = api_runtime
low_resources = await create_workflow_resources(client, include_scene=True)
low_order = await client.post(
"/api/v1/orders",
json={
"customer_level": "low",
"service_mode": "auto_basic",
"model_id": 201,
"model_id": low_resources["model"]["id"],
"pose_id": 11,
"garment_asset_id": 9101,
"scene_ref_asset_id": 8101,
"garment_asset_id": low_resources["garment"]["id"],
"scene_ref_asset_id": low_resources["scene"]["id"],
},
)
assert low_order.status_code == 201
@@ -394,15 +814,16 @@ async def test_workflows_list_returns_recent_runs_with_failure_count(api_runtime
client, env = api_runtime
low_resources = await create_workflow_resources(client, include_scene=True)
low_order = await client.post(
"/api/v1/orders",
json={
"customer_level": "low",
"service_mode": "auto_basic",
"model_id": 301,
"model_id": low_resources["model"]["id"],
"pose_id": 21,
"garment_asset_id": 9201,
"scene_ref_asset_id": 8201,
"garment_asset_id": low_resources["garment"]["id"],
"scene_ref_asset_id": low_resources["scene"]["id"],
},
)
assert low_order.status_code == 201