feat: expand module workflows, dynamic config, notifications and console API
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.adapters.workflow_config import (
|
||||
PostgresWorkflowConfigStore,
|
||||
WorkflowConfigConflictError,
|
||||
normalize_config,
|
||||
normalize_style_config,
|
||||
)
|
||||
from gyxx_flow.console import (
|
||||
ConsoleConflictError,
|
||||
WorkflowConsoleService,
|
||||
create_console_server,
|
||||
)
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.modules.product_commerce.config.style_config_loader import (
|
||||
StyleConfigLoader,
|
||||
)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class FakeDynamicConfigStore:
|
||||
def __init__(self) -> None:
|
||||
self.records = [
|
||||
{
|
||||
"id": 1,
|
||||
"style_name": "极星pro",
|
||||
"erp_codes": ["10398"],
|
||||
"platforms": [
|
||||
{
|
||||
"id": 11,
|
||||
"platform": "天猫",
|
||||
"item_ids": ["832214449817"],
|
||||
"persona_bitable_url": "",
|
||||
"enabled": True,
|
||||
}
|
||||
],
|
||||
"enabled": True,
|
||||
"revision": 1,
|
||||
}
|
||||
]
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
search: str = "",
|
||||
platform: str = "",
|
||||
enabled: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del search, platform, enabled
|
||||
return {
|
||||
"styles": self.records,
|
||||
"summary": {"total": 1, "enabled": 1, "platform_configs": 1, "matched": 1},
|
||||
"platforms": [{"name": "天猫", "count": 1}],
|
||||
"source": "postgresql",
|
||||
}
|
||||
|
||||
def runtime_records(self) -> list[dict[str, Any]]:
|
||||
return self.records
|
||||
|
||||
def create(self, payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {"id": 2, **normalize_style_config(payload), "revision": 1}
|
||||
|
||||
def update(
|
||||
self,
|
||||
config_id: int,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> dict[str, Any]:
|
||||
if expected_revision != 1:
|
||||
raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入")
|
||||
return {
|
||||
"id": config_id,
|
||||
**normalize_style_config(payload),
|
||||
"revision": 2,
|
||||
}
|
||||
|
||||
def delete(self, config_id: int, *, expected_revision: int) -> None:
|
||||
del config_id
|
||||
if expected_revision != 1:
|
||||
raise WorkflowConfigConflictError("配置已被其他人更新,请重新载入")
|
||||
|
||||
|
||||
def _payload() -> dict[str, Any]:
|
||||
return {
|
||||
"style_name": "极星pro",
|
||||
"brand": "光影行星",
|
||||
"erp_codes": "10398,10398, 10400",
|
||||
"note": "",
|
||||
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
|
||||
"main_image_bitable_url": "",
|
||||
"creator_bitable_url": "",
|
||||
"self_creator_bitable_url": "",
|
||||
"weekly_note_analysis_url": "",
|
||||
"style_analysis_bitable_url": "",
|
||||
"style_content": "",
|
||||
"enabled": True,
|
||||
"platforms": [
|
||||
{
|
||||
"platform": "天猫",
|
||||
"item_ids": ["832214449817", "832214449817"],
|
||||
"persona_bitable_url": "",
|
||||
"enabled": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_config_cleans_ids_and_markdown_urls() -> None:
|
||||
payload = {
|
||||
"platform": "天猫",
|
||||
"style_name": "极星pro",
|
||||
"brand": "光影行星",
|
||||
"erp_codes": "10398,10398, 10400",
|
||||
"item_ids": ["832214449817", "832214449817"],
|
||||
"note": "",
|
||||
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
|
||||
"main_image_bitable_url": "",
|
||||
"creator_bitable_url": "",
|
||||
"tm_persona_bitable_url": "",
|
||||
"jd_persona_bitable_url": "",
|
||||
"dy_persona_bitable_url": "",
|
||||
"self_creator_bitable_url": "",
|
||||
"weekly_note_analysis_url": "",
|
||||
"style_analysis_bitable_url": "",
|
||||
"style_content": "",
|
||||
"enabled": True,
|
||||
}
|
||||
payload["sales_bitable_url"] = "[销量表](https://example.feishu.cn/base/abc?table=tbl1)"
|
||||
|
||||
normalized = normalize_config(payload)
|
||||
|
||||
assert normalized["erp_codes"] == ["10398", "10400"]
|
||||
assert normalized["item_ids"] == ["832214449817"]
|
||||
assert normalized["sales_bitable_url"] == (
|
||||
"https://example.feishu.cn/base/abc?table=tbl1"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_style_config_owns_erp_once_and_platform_ids_per_child() -> None:
|
||||
normalized = normalize_style_config(_payload())
|
||||
|
||||
assert normalized["erp_codes"] == ["10398", "10400"]
|
||||
assert normalized["platforms"] == [
|
||||
{
|
||||
"platform": "天猫",
|
||||
"item_ids": ["832214449817"],
|
||||
"persona_bitable_url": "",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_normalize_style_config_supports_extensible_business_targets() -> None:
|
||||
payload = _payload()
|
||||
payload["destinations"] = [
|
||||
{
|
||||
"key": "sales_bitable_url",
|
||||
"label": "销量表",
|
||||
"url": "https://example.feishu.cn/base/sales?table=tblSales",
|
||||
"description": "销量日报写入目标",
|
||||
"enabled": True,
|
||||
},
|
||||
{
|
||||
"key": "style_quality_analysis",
|
||||
"label": "款式质量分析",
|
||||
"url": "https://example.feishu.cn/base/quality?table=tblQuality",
|
||||
"description": "未来分析逻辑写入的结果表",
|
||||
"enabled": True,
|
||||
},
|
||||
]
|
||||
|
||||
normalized = normalize_style_config(payload)
|
||||
|
||||
assert [item["key"] for item in normalized["destinations"]] == [
|
||||
"sales_bitable_url",
|
||||
"style_quality_analysis",
|
||||
]
|
||||
assert normalized["sales_bitable_url"] == (
|
||||
"https://example.feishu.cn/base/sales?table=tblSales"
|
||||
)
|
||||
assert normalized["destinations"][1]["description"] == (
|
||||
"未来分析逻辑写入的结果表"
|
||||
)
|
||||
|
||||
|
||||
def test_postgres_store_omits_empty_dsn_for_split_connection_fields(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from gyxx_flow.adapters import workflow_config
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class FakeConnection:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def connect(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return FakeConnection()
|
||||
|
||||
monkeypatch.setattr(workflow_config.psycopg, "connect", connect)
|
||||
store = PostgresWorkflowConfigStore(
|
||||
{
|
||||
"PG_HOST": "db.example.test",
|
||||
"PG_PORT": "5432",
|
||||
"PG_DB": "app",
|
||||
"PG_USER": "app",
|
||||
"PG_PASSWORD": "test-only",
|
||||
}
|
||||
)
|
||||
|
||||
with store._connection():
|
||||
pass
|
||||
|
||||
assert calls[0]["host"] == "db.example.test"
|
||||
assert "conninfo" not in calls[0]
|
||||
|
||||
|
||||
def test_migration_seed_captures_all_feishu_records() -> None:
|
||||
seed = json.loads(
|
||||
(PROJECT_ROOT / "config" / "workflow-dynamic-config-seed.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
|
||||
assert seed["record_count"] == 215
|
||||
assert len(seed["records"]) == 215
|
||||
assert len({item["source_record_id"] for item in seed["records"]}) == 215
|
||||
assert any(
|
||||
item["config"]["style_name"] == "极星pro"
|
||||
and item["config"]["platform"] == "天猫"
|
||||
for item in seed["records"]
|
||||
)
|
||||
|
||||
|
||||
def test_style_loader_reads_database_provider_and_never_needs_feishu(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
loader = StyleConfigLoader(
|
||||
cache_path=tmp_path / "style-cache.json",
|
||||
records_provider=lambda: [
|
||||
{
|
||||
**normalize_config(
|
||||
{
|
||||
"platform": "天猫",
|
||||
"style_name": "极星pro",
|
||||
"brand": "光影行星",
|
||||
"erp_codes": ["10398", "10400"],
|
||||
"item_ids": ["832214449817"],
|
||||
"note": "",
|
||||
"sales_bitable_url": "https://example.feishu.cn/base/abc?table=tbl1",
|
||||
"main_image_bitable_url": "",
|
||||
"creator_bitable_url": "",
|
||||
"tm_persona_bitable_url": "",
|
||||
"jd_persona_bitable_url": "",
|
||||
"dy_persona_bitable_url": "",
|
||||
"self_creator_bitable_url": "",
|
||||
"weekly_note_analysis_url": "",
|
||||
"style_analysis_bitable_url": "",
|
||||
"style_content": "",
|
||||
"enabled": True,
|
||||
}
|
||||
),
|
||||
"sales_bitable_url": (
|
||||
"https://example.feishu.cn/base/abc?table=tbl1&view=vew1"
|
||||
),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
loaded = loader.load()
|
||||
|
||||
assert loaded["source"] == "database"
|
||||
assert loaded["styles"]["极星pro"]["tm"]["item_ids"] == [
|
||||
"832214449817"
|
||||
]
|
||||
assert loaded["styles"]["极星pro"]["erp_codes"] == ["10398", "10400"]
|
||||
|
||||
|
||||
def test_style_loader_preserves_extensible_business_targets(tmp_path: Path) -> None:
|
||||
destinations = [
|
||||
{
|
||||
"key": "style_quality_analysis",
|
||||
"label": "款式质量分析",
|
||||
"url": "https://example.feishu.cn/base/quality?table=tblQuality",
|
||||
"description": "质量分析结果表",
|
||||
"enabled": True,
|
||||
}
|
||||
]
|
||||
loader = StyleConfigLoader(
|
||||
cache_path=tmp_path / "style-cache.json",
|
||||
records_provider=lambda: [
|
||||
{
|
||||
"platform": "天猫",
|
||||
"style_name": "极星pro",
|
||||
"brand": "光影行星",
|
||||
"erp_codes": ["10398"],
|
||||
"item_ids": ["832214449817"],
|
||||
"sales_bitable_url": (
|
||||
"https://example.feishu.cn/base/sales?table=tblSales"
|
||||
),
|
||||
"destinations": destinations,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
loaded = loader.load()
|
||||
|
||||
assert loaded["styles"]["极星pro"]["destinations"] == destinations
|
||||
|
||||
|
||||
def test_grouped_database_keeps_original_platform_and_target_contracts() -> None:
|
||||
shared = {
|
||||
"style_name": "款式甲",
|
||||
"brand": "光影行星",
|
||||
"erp_codes": ["ERP-1"],
|
||||
"sales_bitable_url": "https://example.feishu.cn/base/sales?table=tblSales",
|
||||
"main_image_bitable_url": "https://example.feishu.cn/base/image?table=tblImage",
|
||||
"tm_persona_bitable_url": "https://example.feishu.cn/base/tm?table=tblTm",
|
||||
"jd_persona_bitable_url": "https://example.feishu.cn/base/jd?table=tblJd",
|
||||
"dy_persona_bitable_url": "https://example.feishu.cn/base/dy?table=tblDy",
|
||||
"style_analysis_bitable_url": "https://example.feishu.cn/base/report?table=tblReport",
|
||||
}
|
||||
rows = [
|
||||
{**shared, "platform": "天猫", "item_ids": ["TM-1"]},
|
||||
{**shared, "platform": "京东旗舰店", "item_ids": ["JD-SPU-1"]},
|
||||
{**shared, "platform": "京东自营", "item_ids": ["JD-SKU-1"]},
|
||||
{**shared, "platform": "抖音", "item_ids": ["DY-1"]},
|
||||
]
|
||||
loader = StyleConfigLoader(records_provider=lambda: rows)
|
||||
|
||||
style = loader._aggregate(loader._load_records_from_database())["styles"]["款式甲"]
|
||||
|
||||
assert style["erp_codes"] == ["ERP-1"]
|
||||
assert style["tm"]["item_ids"] == ["TM-1"]
|
||||
assert style["jd"]["spus"] == ["JD-SPU-1"]
|
||||
assert style["jd_self"]["skus"] == ["JD-SKU-1"]
|
||||
assert style["dy"]["item_ids"] == ["DY-1"]
|
||||
assert style["sales_bitable"]["table_id"] == "tblSales"
|
||||
assert style["main_image_bitable"]["table_id"] == "tblImage"
|
||||
assert style["persona_bitable"]["table_id"] == "tblTm"
|
||||
assert style["jd_persona_bitable"]["table_id"] == "tblJd"
|
||||
assert style["dy_persona_bitable"]["table_id"] == "tblDy"
|
||||
assert style["style_analysis_bitable"]["table_id"] == "tblReport"
|
||||
|
||||
|
||||
def test_console_dynamic_config_crud_maps_optimistic_conflicts(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = WorkflowConsoleService(
|
||||
Settings(project_root=PROJECT_ROOT, data_root=tmp_path),
|
||||
dynamic_configs=FakeDynamicConfigStore(),
|
||||
)
|
||||
|
||||
assert service.dynamic_config_snapshot()["summary"]["total"] == 1
|
||||
created = service.create_dynamic_config(_payload())
|
||||
assert created["revision"] == 1
|
||||
updated = service.update_dynamic_config(1, _payload(), expected_revision=1)
|
||||
assert updated["revision"] == 2
|
||||
with pytest.raises(ConsoleConflictError, match="其他人更新"):
|
||||
service.update_dynamic_config(1, _payload(), expected_revision=9)
|
||||
|
||||
|
||||
def test_console_assets_include_dynamic_config_crud_page() -> None:
|
||||
html = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "index.html").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
script = (PROJECT_ROOT / "src" / "gyxx_flow" / "web" / "app.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert 'id="dynamic-config-view"' in html
|
||||
assert 'id="dynamic-config-form"' in html
|
||||
assert "/api/dynamic-configs" in script
|
||||
assert "#/configs/products" in script
|
||||
|
||||
|
||||
def test_content_summary_targets_are_loaded_from_project_database(monkeypatch) -> None:
|
||||
from gyxx_flow.modules.content_marketing import weekly_summary_all
|
||||
|
||||
monkeypatch.setattr(
|
||||
weekly_summary_all,
|
||||
"runtime_workflow_configs",
|
||||
lambda: [
|
||||
{
|
||||
"style_name": "极星pro",
|
||||
"weekly_note_analysis_url": (
|
||||
"https://example.feishu.cn/base/base1?table=table1&view=view1"
|
||||
),
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert weekly_summary_all.load_index_styles() == {
|
||||
"极星pro": (
|
||||
"base1",
|
||||
"table1",
|
||||
"https://example.feishu.cn/base/base1?table=table1&view=view1",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def test_content_mapping_rejects_legacy_static_cache(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
|
||||
cache_path = tmp_path / "mapping.json"
|
||||
cache_path.write_text(
|
||||
json.dumps({"platform": "xhs_pgy", "tables": [{"name": "旧款"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(feishu_mapping, "MAPPING_PATH", cache_path)
|
||||
monkeypatch.setattr(feishu_mapping, "refresh_mapping", lambda **_kwargs: None)
|
||||
|
||||
with pytest.raises(RuntimeError, match="项目数据库不可用"):
|
||||
feishu_mapping.load_mapping(force_refresh=False)
|
||||
|
||||
|
||||
def test_retired_feishu_master_is_not_referenced_by_runtime_modules() -> None:
|
||||
retired_token = "TtoCb1NuQaDy3NsZWTpc0GIvnph"
|
||||
runtime_root = PROJECT_ROOT / "src" / "gyxx_flow"
|
||||
offenders = []
|
||||
for path in runtime_root.rglob("*.py"):
|
||||
if path == runtime_root / "migration" / "feishu_style_config_seed.py":
|
||||
continue
|
||||
if retired_token in path.read_text(encoding="utf-8"):
|
||||
offenders.append(path.relative_to(PROJECT_ROOT).as_posix())
|
||||
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_dynamic_config_http_crud_uses_revision_preconditions(tmp_path: Path) -> None:
|
||||
server = create_console_server(
|
||||
Settings(project_root=PROJECT_ROOT, data_root=tmp_path),
|
||||
host="127.0.0.1",
|
||||
port=0,
|
||||
dynamic_configs=FakeDynamicConfigStore(),
|
||||
)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base_url = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
revision: int | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-GYXX-Console": "1",
|
||||
"Origin": base_url,
|
||||
}
|
||||
if revision is not None:
|
||||
headers["If-Match"] = str(revision)
|
||||
response = urlopen(
|
||||
Request(base_url + path, data=data, headers=headers, method=method),
|
||||
timeout=5,
|
||||
)
|
||||
return response.status, json.loads(response.read().decode("utf-8"))
|
||||
|
||||
try:
|
||||
status, snapshot = request("GET", "/api/dynamic-configs")
|
||||
assert status == 200
|
||||
assert snapshot["summary"]["total"] == 1
|
||||
|
||||
status, created = request("POST", "/api/dynamic-configs", _payload())
|
||||
assert status == 201
|
||||
assert created["revision"] == 1
|
||||
|
||||
status, updated = request(
|
||||
"PUT", "/api/dynamic-configs/1", _payload(), revision=1
|
||||
)
|
||||
assert status == 200
|
||||
assert updated["revision"] == 2
|
||||
|
||||
status, deleted = request(
|
||||
"DELETE", "/api/dynamic-configs/1", revision=1
|
||||
)
|
||||
assert status == 200
|
||||
assert deleted == {"deleted": True, "id": 1}
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=5)
|
||||
Reference in New Issue
Block a user