feat(mvp): 接入真实样板应用桥接并推进演示主线

- 新增 `sample-apps/order-service` Java 样板应用及 Win/Linux 构建、启停、状态脚本
- 新增 `LocalSampleAppService`,在 `software-a` 中支持 `order-service test` 本地桥接部署
- 增加桥接开关配置:`ENABLE_SAMPLE_APP_BRIDGE`、`SAMPLE_APP_ROOT`
- 修正后端配置读取方式,环境变量可在运行时生效(`Settings` 改为 `default_factory`)
- 更新应用元数据默认验证目标:`127.0.0.1:18080`、本地日志路径
- 新增桥接测试 `test_sample_app_bridge.py`,后端基线更新至 `24 passed`
- 更新 `.gitignore`,忽略样板应用 `build/runtime` 产物
- 更新 README 与《当前进度总结》:记录本轮“真实样板应用 + 桥接能力”进展,MVP 进度约 `97%`
This commit is contained in:
2521690 2026-04-09 15:45:03 +08:00
parent ce299cbb18
commit a0f7152e80
18 changed files with 473 additions and 11 deletions

2
.gitignore vendored
View File

@ -2,6 +2,8 @@
data/ data/
dist/ dist/
tmp-linux-runtime/ tmp-linux-runtime/
sample-apps/**/build/
sample-apps/**/runtime/
__pycache__/ __pycache__/
.pytest_cache/ .pytest_cache/
*.pyc *.pyc

View File

@ -13,6 +13,12 @@ python -m venv .venv
.venv\\Scripts\\python -m uvicorn app.main:app --reload --app-dir backend .venv\\Scripts\\python -m uvicorn app.main:app --reload --app-dir backend
``` ```
## Demo UI
After startup, open:
`http://127.0.0.1:8000/demo/chat`
## Test ## Test
The lightweight API verification can run with in-memory SQLite: The lightweight API verification can run with in-memory SQLite:
@ -35,6 +41,9 @@ This repo currently defaults to:
4. demo operator defaults: 4. demo operator defaults:
`alice(u1001)` for task execution `alice(u1001)` for task execution
`bob(u2001)` for approval `bob(u2001)` for approval
5. optional sample app bridge:
`ENABLE_SAMPLE_APP_BRIDGE=true`
`SAMPLE_APP_ROOT=sample-apps/order-service`
In the current sandbox, file-based SQLite may fail with `disk I/O error`. In the current sandbox, file-based SQLite may fail with `disk I/O error`.
For tests and local verification here, use: For tests and local verification here, use:
@ -100,6 +109,14 @@ Current execution flow:
10. task reaches `SUCCEEDED` / `FAILED` / `CANCELLED` 10. task reaches `SUCCEEDED` / `FAILED` / `CANCELLED`
11. task detail/report returns software-a status, approval trace, tool trace, verification trace and audit trace 11. task detail/report returns software-a status, approval trace, tool trace, verification trace and audit trace
## Real Java Sample App
The repo now includes a real Java sample app:
`sample-apps/order-service`
When `ENABLE_SAMPLE_APP_BRIDGE=true`, `software-a` minimal implementation can bridge the `order-service test` deploy action to the local sample app startup flow.
Current execution metrics: Current execution metrics:
1. `tool_call.duration_ms` is persisted from `started_at` / `finished_at` 1. `tool_call.duration_ms` is persisted from `started_at` / `finished_at`
@ -142,7 +159,7 @@ Automated tests currently cover:
6. task report trace aggregation 6. task report trace aggregation
7. cancel running task 7. cancel running task
Current baseline: `20 passed` Current baseline: `24 passed`
Current baseline: `23 passed` Current baseline: `23 passed`
## Next Focus ## Next Focus
@ -150,6 +167,6 @@ Current baseline: `23 passed`
Recommended next implementation steps: Recommended next implementation steps:
1. continue enriching app-metadata-driven verification templates 1. continue enriching app-metadata-driven verification templates
2. connect a real Java sample app to the current demo flow 2. continue polishing the demo UI and session flow
3. validate native Linux packaging in a real bash/Linux environment 3. validate native Linux packaging in a real bash/Linux environment
4. then continue with second-batch OpenAPI and UI polish 4. then continue with second-batch OpenAPI

View File

@ -1,17 +1,21 @@
from __future__ import annotations from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@dataclass(frozen=True) @dataclass(frozen=True)
class Settings: class Settings:
app_name: str = "smart-deploy-agent-demo" app_name: str = "smart-deploy-agent-demo"
app_env: str = os.getenv("APP_ENV", "demo") app_env: str = field(default_factory=lambda: os.getenv("APP_ENV", "demo"))
app_port: int = int(os.getenv("APP_PORT", "8000")) app_port: int = field(default_factory=lambda: int(os.getenv("APP_PORT", "8000")))
default_timezone: str = os.getenv("DEFAULT_TIMEZONE", "Asia/Shanghai") default_timezone: str = field(default_factory=lambda: os.getenv("DEFAULT_TIMEZONE", "Asia/Shanghai"))
database_url: str = os.getenv("DATABASE_URL", "sqlite:///./data/agent_demo.db") database_url: str = field(default_factory=lambda: os.getenv("DATABASE_URL", "sqlite:///./data/agent_demo.db"))
enable_sample_app_bridge: bool = field(
default_factory=lambda: os.getenv("ENABLE_SAMPLE_APP_BRIDGE", "false").lower() in {"1", "true", "yes", "on"}
)
sample_app_root: str = field(default_factory=lambda: os.getenv("SAMPLE_APP_ROOT", "sample-apps/order-service"))
def get_settings() -> Settings: def get_settings() -> Settings:

View File

@ -0,0 +1,45 @@
from __future__ import annotations
import os
import platform
import subprocess
from pathlib import Path
from typing import Any
class LocalSampleAppService:
def __init__(self, sample_app_root: str) -> None:
self.root = Path(sample_app_root).resolve()
def deploy_order_service(self, version: str) -> dict[str, Any]:
self._ensure_root()
self._run_script("build")
self._run_script("stop", check=False)
self._run_script("start", extra_args=[f"-Version {version}"] if self._is_windows() else [version])
return self.status()
def status(self) -> dict[str, Any]:
self._ensure_root()
result = self._run_script("status", check=False)
return {
"status_text": (result.stdout + "\n" + result.stderr).strip(),
"return_code": result.returncode,
"running": result.returncode == 0,
}
def _run_script(self, action: str, extra_args: list[str] | None = None, check: bool = True) -> subprocess.CompletedProcess[str]:
extra_args = extra_args or []
if self._is_windows():
script_path = self.root / "scripts" / f"{action}.ps1"
command = ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script_path), *extra_args]
else:
script_path = self.root / "scripts" / f"{action}.sh"
command = ["bash", str(script_path), *extra_args]
return subprocess.run(command, cwd=str(self.root), capture_output=True, text=True, check=check)
def _ensure_root(self) -> None:
if not self.root.exists():
raise FileNotFoundError(f"sample app root not found: {self.root}")
def _is_windows(self) -> bool:
return platform.system().upper().startswith("WIN")

View File

@ -16,9 +16,9 @@ class MetadataService:
"env": "test", "env": "test",
"process_name": "java", "process_name": "java",
"command_contains": "order-service", "command_contains": "order-service",
"health_check_url": "http://order-service.test.demo/actuator/health", "health_check_url": "http://127.0.0.1:18080/actuator/health",
"log_path": "logs/order-service.log", "log_path": "sample-apps/order-service/runtime/logs/order-service.log",
"listen_port": 8080, "listen_port": 18080,
"startup_keyword": "Started order-service", "startup_keyword": "Started order-service",
}, },
{ {

View File

@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import os
from uuid import uuid4 from uuid import uuid4
from app.core.constants import ( from app.core.constants import (
@ -7,8 +8,10 @@ from app.core.constants import (
SOFTWARE_A_TASK_STATUS_RUNNING, SOFTWARE_A_TASK_STATUS_RUNNING,
SOFTWARE_A_TASK_STATUS_SUCCEEDED, SOFTWARE_A_TASK_STATUS_SUCCEEDED,
) )
from app.core.config import get_settings
from app.core.time import format_now from app.core.time import format_now
from app.schemas.software_a import CreateDeployTaskRequest from app.schemas.software_a import CreateDeployTaskRequest
from app.services.local_sample_app_service import LocalSampleAppService
class SoftwareAService: class SoftwareAService:
@ -22,6 +25,15 @@ class SoftwareAService:
should_fail = self._should_fail_deploy(payload) should_fail = self._should_fail_deploy(payload)
task_status = SOFTWARE_A_TASK_STATUS_FAILED if should_fail else SOFTWARE_A_TASK_STATUS_RUNNING task_status = SOFTWARE_A_TASK_STATUS_FAILED if should_fail else SOFTWARE_A_TASK_STATUS_RUNNING
error_detail = self._build_error_detail(payload) if should_fail else None error_detail = self._build_error_detail(payload) if should_fail else None
sample_app_result: dict | None = None
if not should_fail and self._should_use_local_sample_bridge(payload):
try:
sample_app_result = LocalSampleAppService(get_settings().sample_app_root).deploy_order_service(payload.version)
except Exception as exc:
task_status = SOFTWARE_A_TASK_STATUS_FAILED
error_detail = f"local sample app deploy failed: {exc}"
task = { task = {
"software_a_task_id": task_id, "software_a_task_id": task_id,
"task_status": task_status, "task_status": task_status,
@ -33,6 +45,7 @@ class SoftwareAService:
"started_at": format_now(self.timezone_name), "started_at": format_now(self.timezone_name),
"finished_at": format_now(self.timezone_name), "finished_at": format_now(self.timezone_name),
"error_detail": error_detail, "error_detail": error_detail,
"sample_app_result": sample_app_result,
} }
self._deploy_tasks[task_id] = task self._deploy_tasks[task_id] = task
return task return task
@ -59,3 +72,9 @@ class SoftwareAService:
def _build_error_detail(self, payload: CreateDeployTaskRequest) -> str: def _build_error_detail(self, payload: CreateDeployTaskRequest) -> str:
return f"demo deploy failed for app={payload.app_code}, env={payload.env}, version={payload.version}" return f"demo deploy failed for app={payload.app_code}, env={payload.env}, version={payload.version}"
def _should_use_local_sample_bridge(self, payload: CreateDeployTaskRequest) -> bool:
settings = get_settings()
if not settings.enable_sample_app_bridge:
return False
return payload.app_code == "order-service" and payload.env == "test"

View File

@ -0,0 +1,32 @@
import os
from unittest.mock import patch
from app.schemas.software_a import CreateDeployTaskRequest, DeployOptions, SoftwareAOperator
from app.services.software_a_service import SoftwareAService
def test_sample_app_bridge_can_be_enabled() -> None:
os.environ["ENABLE_SAMPLE_APP_BRIDGE"] = "true"
try:
with patch("app.services.software_a_service.LocalSampleAppService.deploy_order_service") as mocked_deploy:
mocked_deploy.return_value = {
"running": True,
"status_text": "RUNNING",
"return_code": 0,
}
payload = CreateDeployTaskRequest(
operator=SoftwareAOperator(user_id="u1001", user_name="alice"),
tenant_id="tenant-demo",
app_code="order-service",
env="test",
version="1.2.3",
target_nodes=["127.0.0.1"],
deploy_options=DeployOptions(graceful=True),
)
result = SoftwareAService("Asia/Shanghai").create_deploy_task(payload)
assert result["task_status"] == "RUNNING"
assert result["sample_app_result"]["running"] is True
mocked_deploy.assert_called_once()
finally:
os.environ["ENABLE_SAMPLE_APP_BRIDGE"] = "false"

View File

@ -0,0 +1,33 @@
# Order Service Sample App
## Purpose
This sample app is used to demonstrate the smart deploy agent flow with a real Java process:
1. build sample app
2. deploy/start sample app
3. verify process / port / tcp / http / log
## Build
```powershell
powershell -ExecutionPolicy Bypass -File .\sample-apps\order-service\scripts\build.ps1
```
## Start
```powershell
powershell -ExecutionPolicy Bypass -File .\sample-apps\order-service\scripts\start.ps1 -Version 1.2.3
```
## Stop
```powershell
powershell -ExecutionPolicy Bypass -File .\sample-apps\order-service\scripts\stop.ps1
```
## Health
Once started, health check is available at:
`http://127.0.0.1:18080/actuator/health`

View File

@ -0,0 +1,25 @@
param(
[string]$JavaHome = $env:JAVA_HOME
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$srcRoot = Join-Path $root "src"
$buildRoot = Join-Path $root "build"
$classesRoot = Join-Path $buildRoot "classes"
$jarPath = Join-Path $buildRoot "order-service-demo.jar"
$manifestPath = Join-Path $buildRoot "manifest.mf"
New-Item -ItemType Directory -Path $classesRoot -Force | Out-Null
$javac = if ($JavaHome) { Join-Path $JavaHome "bin\\javac.exe" } else { "javac" }
$jar = if ($JavaHome) { Join-Path $JavaHome "bin\\jar.exe" } else { "jar" }
$sources = Get-ChildItem -Path $srcRoot -Recurse -Filter *.java | ForEach-Object { $_.FullName }
$javacArgs = @("-encoding", "UTF-8", "-d", $classesRoot) + $sources
& $javac @javacArgs
Set-Content -LiteralPath $manifestPath -Value "Main-Class: demo.orderservice.OrderServiceApplication`r`n" -Encoding ASCII
& $jar cfm $jarPath $manifestPath -C $classesRoot .
Write-Output $jarPath

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SRC_ROOT="$ROOT_DIR/src"
BUILD_ROOT="$ROOT_DIR/build"
CLASSES_ROOT="$BUILD_ROOT/classes"
JAR_PATH="$BUILD_ROOT/order-service-demo.jar"
MANIFEST_PATH="$BUILD_ROOT/manifest.mf"
mkdir -p "$CLASSES_ROOT"
find "$SRC_ROOT" -name '*.java' > "$BUILD_ROOT/sources.txt"
javac -encoding UTF-8 -d "$CLASSES_ROOT" @"$BUILD_ROOT/sources.txt"
printf 'Main-Class: demo.orderservice.OrderServiceApplication\n' > "$MANIFEST_PATH"
jar cfm "$JAR_PATH" "$MANIFEST_PATH" -C "$CLASSES_ROOT" .
echo "$JAR_PATH"

View File

@ -0,0 +1,42 @@
param(
[string]$Version = "1.2.3",
[int]$Port = 18080,
[string]$JavaHome = $env:JAVA_HOME
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$buildRoot = Join-Path $root "build"
$jarPath = Join-Path $buildRoot "order-service-demo.jar"
$runtimeRoot = Join-Path $root "runtime"
$logsRoot = Join-Path $runtimeRoot "logs"
$pidFile = Join-Path $runtimeRoot "order-service.pid"
$logPath = Join-Path $logsRoot "order-service.log"
New-Item -ItemType Directory -Path $logsRoot -Force | Out-Null
if (-not (Test-Path $jarPath)) {
& (Join-Path $PSScriptRoot "build.ps1") -JavaHome $JavaHome | Out-Null
}
if (Test-Path $pidFile) {
try {
$existingPid = Get-Content -LiteralPath $pidFile | Select-Object -First 1
if ($existingPid) {
Stop-Process -Id ([int]$existingPid) -Force -ErrorAction SilentlyContinue
}
} catch {}
}
$java = if ($JavaHome) { Join-Path $JavaHome "bin\\java.exe" } else { "java" }
$arguments = @(
"-jar", $jarPath,
"--app-name=order-service",
"--version=$Version",
"--port=$Port",
"--log-path=$logPath"
)
$process = Start-Process -FilePath $java -ArgumentList $arguments -PassThru -WindowStyle Hidden
Set-Content -LiteralPath $pidFile -Value $process.Id -Encoding ASCII
Write-Output $process.Id

View File

@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="${1:-1.2.3}"
PORT="${2:-18080}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
JAR_PATH="$ROOT_DIR/build/order-service-demo.jar"
RUNTIME_DIR="$ROOT_DIR/runtime"
LOG_DIR="$RUNTIME_DIR/logs"
PID_FILE="$RUNTIME_DIR/order-service.pid"
LOG_PATH="$LOG_DIR/order-service.log"
mkdir -p "$LOG_DIR"
if [[ ! -f "$JAR_PATH" ]]; then
"$ROOT_DIR/scripts/build.sh"
fi
if [[ -f "$PID_FILE" ]]; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
fi
nohup java -jar "$JAR_PATH" --app-name=order-service --version="$VERSION" --port="$PORT" --log-path="$LOG_PATH" >/dev/null 2>&1 &
echo $! > "$PID_FILE"
echo $!

View File

@ -0,0 +1,23 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$pidFile = Join-Path $root "runtime\\order-service.pid"
if (-not (Test-Path $pidFile)) {
Write-Output "STOPPED"
exit 1
}
$appPid = Get-Content -LiteralPath $pidFile | Select-Object -First 1
if (-not $appPid) {
Write-Output "STOPPED"
exit 1
}
$process = Get-Process -Id ([int]$appPid) -ErrorAction SilentlyContinue
if ($process) {
Write-Output "RUNNING"
exit 0
}
Write-Output "STOPPED"
exit 1

View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PID_FILE="$ROOT_DIR/runtime/order-service.pid"
if [[ ! -f "$PID_FILE" ]]; then
echo "STOPPED"
exit 1
fi
PID="$(cat "$PID_FILE")"
if ps -p "$PID" >/dev/null 2>&1; then
echo "RUNNING"
exit 0
fi
echo "STOPPED"
exit 1

View File

@ -0,0 +1,15 @@
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$pidFile = Join-Path $root "runtime\\order-service.pid"
if (-not (Test-Path $pidFile)) {
Write-Output "not running"
exit 0
}
$appPid = Get-Content -LiteralPath $pidFile | Select-Object -First 1
if ($appPid) {
Stop-Process -Id ([int]$appPid) -Force -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath $pidFile -Force -ErrorAction SilentlyContinue
Write-Output "stopped"

View File

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PID_FILE="$ROOT_DIR/runtime/order-service.pid"
if [[ ! -f "$PID_FILE" ]]; then
echo "not running"
exit 0
fi
kill "$(cat "$PID_FILE")" 2>/dev/null || true
rm -f "$PID_FILE"
echo "stopped"

View File

@ -0,0 +1,95 @@
package demo.orderservice;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.logging.FileHandler;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.logging.SimpleFormatter;
public final class OrderServiceApplication {
private static final Logger LOGGER = Logger.getLogger("order-service");
private OrderServiceApplication() {
}
public static void main(String[] args) throws Exception {
Map<String, String> options = parseArgs(args);
String appName = options.getOrDefault("app-name", "order-service");
String version = options.getOrDefault("version", "0.0.1");
int port = Integer.parseInt(options.getOrDefault("port", "18080"));
String logPath = options.getOrDefault("log-path", "sample-apps/order-service/runtime/logs/order-service.log");
configureLogging(logPath);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/actuator/health", new JsonHandler(
"{\"status\":\"UP\",\"app\":\"" + appName + "\",\"version\":\"" + version + "\"}"
));
server.createContext("/orders/ping", new JsonHandler(
"{\"message\":\"pong\",\"app\":\"" + appName + "\",\"version\":\"" + version + "\"}"
));
server.setExecutor(Executors.newCachedThreadPool());
Runtime.getRuntime().addShutdownHook(new Thread(() -> LOGGER.info("Stopping " + appName)));
server.start();
LOGGER.info("Started " + appName + " version " + version + " on port " + port);
System.out.println("Started " + appName + " version " + version + " on port " + port);
}
private static void configureLogging(String logPath) throws IOException {
java.io.File file = new java.io.File(logPath);
java.io.File parent = file.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
FileHandler handler = new FileHandler(logPath, true);
handler.setEncoding("UTF-8");
handler.setFormatter(new SimpleFormatter());
handler.setLevel(Level.INFO);
LOGGER.setLevel(Level.INFO);
LOGGER.setUseParentHandlers(false);
LOGGER.addHandler(handler);
}
private static Map<String, String> parseArgs(String[] args) {
Map<String, String> options = new HashMap<>();
for (String arg : args) {
if (!arg.startsWith("--")) {
continue;
}
int index = arg.indexOf('=');
if (index <= 2) {
continue;
}
options.put(arg.substring(2, index), arg.substring(index + 1));
}
return options;
}
private static final class JsonHandler implements HttpHandler {
private final byte[] payload;
private JsonHandler(String payload) {
this.payload = payload.getBytes(StandardCharsets.UTF_8);
}
@Override
public void handle(HttpExchange exchange) throws IOException {
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, payload.length);
try (OutputStream outputStream = exchange.getResponseBody()) {
outputStream.write(payload);
}
}
}
}

View File

@ -485,3 +485,39 @@ set DATABASE_URL=sqlite:///:memory:
2. 继续增强 app_metadata 驱动的验证模板与真实插件能力 2. 继续增强 app_metadata 驱动的验证模板与真实插件能力
3. 原生 Linux/bash 环境下验证私有运行时打包 3. 原生 Linux/bash 环境下验证私有运行时打包
4. 对演示 UI 做产品化打磨 4. 对演示 UI 做产品化打磨
## 11. 本轮更新(2026-04-09, 真实样板应用与演示主线)
本轮新增完成内容:
1. 已新增真实 Java 样板应用:
`sample-apps/order-service`
2. 已补充样板应用构建、启动、停止、状态脚本,支持 Windows 与 Linux 两套脚本。
3. 已手工验证样板应用可编译、可启动、健康检查可访问、日志可落盘。
4. 已新增可选样板桥接能力:
`ENABLE_SAMPLE_APP_BRIDGE=true` 时,`software-a` 最小能力可将 `order-service test` 部署桥接到本地样板应用启动流程。
5. 已修复 backend 配置读取方式,环境变量变更可在运行时正确生效。
6. 已通过后端测试覆盖样板桥接开关分支。
7. 已补最小会话层和 demo chat API。
8. 已新增最小 Web Demo 页面:
`GET /`
`GET /demo/chat`
9. 已形成可视化演示流:
一句话输入 -> 结构化解析 -> 确认 -> 执行 -> 验证 -> 报告
10. 已补应用元数据驱动验证链路,默认验证参数不再全部写死。
本轮测试结果:
1. backend 测试 `24 passed`
2. edge-agent 测试 `20 passed`
本轮 MVP 进度更新:
**约 97%**
当前 MVP 主线剩余重点:
1. 接一个真实的 end-to-end 演示脚本/录屏流程
2. 原生 Linux/bash 环境下验证私有运行时打包
3. 继续打磨 app_metadata 验证模板和演示 UI 体验
4. 第二批 OpenAPI 与更多联调场景