Compare commits

..

2 Commits

Author SHA1 Message Date
xiaoxia 1692ae135a fix(ci): 飞书通知消息添加CI构建关键词以通过关键词校验
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m49s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Successful in 2m10s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m32s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 1m29s
2026-07-14 19:03:32 +08:00
xiaoxia 3dc7a0a0bb fix(ci): 飞书通知失败不阻塞部署流程 - 添加continue-on-error 2026-07-14 19:03:21 +08:00
14 changed files with 86 additions and 227 deletions
+13 -13
View File
@@ -235,7 +235,7 @@ jobs:
integration-tests:
name: Integration Tests
runs-on: host
timeout-minutes: 30
timeout-minutes: 20
if: always()
needs: validate
env:
@@ -371,7 +371,7 @@ jobs:
'
- name: Install dependencies
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'rm -rf node_modules/* 2>/dev/null; npm ci --include=dev'\n"
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npm ci'\n"
- name: Run ESLint
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx eslint src --ext .ts,.tsx --max-warnings 50'\n"
@@ -383,7 +383,7 @@ jobs:
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx prettier --check \"src/**/*.{ts,tsx,md}\"'\n"
- name: Run Vitest tests
shell: sh
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'ls node_modules/.bin/vitest 2>/dev/null && node_modules/.bin/vitest run src/test || npm exec vitest run src/test'\n"
run: "set -eu\nNPM_CACHE_VOLUME=\"xiaoxia-npm-cache\"\nif ! docker volume inspect \"$NPM_CACHE_VOLUME\" >/dev/null 2>&1; then\n docker volume create \"$NPM_CACHE_VOLUME\" >/dev/null\n echo \"Created npm cache volume: $NPM_CACHE_VOLUME\"\nfi\n\ndocker run --rm \\\n -v \"$PWD:/workspace\" \\\n -v \"$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules\" -w /workspace/apps/web \\\n docker.m.daocloud.io/library/node:20 \\\n sh -lc 'npx vitest run src/test'\n"
- name: Job duration summary
if: always()
shell: sh
@@ -404,7 +404,7 @@ jobs:
runs-on:
- saas
- build-farm
timeout-minutes: 30
timeout-minutes: 20
needs:
- validate
- frontend-lint
@@ -466,7 +466,7 @@ jobs:
runs-on:
- saas
- build-farm
timeout-minutes: 40
timeout-minutes: 20
needs:
- validate
- frontend-lint
@@ -506,7 +506,7 @@ jobs:
run: "set -eu\n# 确保使用 docker-container driver 以支持 cache export 功能\nif ! docker buildx inspect ci-builder > /dev/null 2>&1; then\n docker buildx create --use --name ci-builder --driver docker-container\n echo \"Created ci-builder (docker-container driver)\"\nelse\n docker buildx use ci-builder\n echo \"Using existing ci-builder\"\nfi\ndocker buildx inspect --bootstrap\n"
- name: Build and push Worker image (buildx cache)
shell: sh
run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-worker\"\nCACHE_REF=\"${REGISTRY}/worker-cache:develop\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=min\"\n echo \"Building Worker image with read-write cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building Worker image with read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push \
run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\nIMAGE_NAME=\"xiaoxia-saas-worker\"\nCACHE_REF=\"${REGISTRY}/worker-cache:develop\"\n\nCACHE_FROM=\"type=registry,ref=${CACHE_REF},ignore-error=true\"\n\nif [ \"${CACHE_MODE}\" = \"read-write\" ]; then\n CACHE_TO=\"type=registry,ref=${CACHE_REF},mode=max\"\n echo \"Building Worker image with read-write cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" --cache-to \"${CACHE_TO}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push .\nelse\n echo \"Building Worker image with read-only cache...\"\n docker buildx build --build-arg APP_VERSION=\"${GITHUB_SHA}\" --cache-from \"${CACHE_FROM}\" -f infra/docker/worker.Dockerfile -t \"${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\" --push \
\ .\nfi\necho \"Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${GITHUB_SHA}\"\n"
- name: Job duration summary
if: always()
@@ -528,7 +528,7 @@ jobs:
runs-on:
- saas
- build-farm
timeout-minutes: 30
timeout-minutes: 20
needs:
- validate
- frontend-lint
@@ -639,7 +639,7 @@ jobs:
run: "set -eu\nREGISTRY=\"git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas\"\n\nfor svc in api worker web; do\n echo \"Pulling ${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA} ...\"\n docker pull \"${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}\"\n docker tag \"${REGISTRY}/xiaoxia-saas-${svc}:${GITHUB_SHA}\" \"${REGISTRY}/xiaoxia-saas-${svc}:staging\"\n docker push \"${REGISTRY}/xiaoxia-saas-${svc}:staging\"\n echo \"$svc :staging tagged and pushed\"\ndone\necho \"All :staging images pushed. Watchtower will auto-deploy within 60s.\"\n"
- name: Wait for Watchtower update + smoke test
shell: sh
run: "set -eu\necho \"Waiting 90s for Watchtower to detect new image and restart containers...\"\nsleep 90\n\necho \"--- Smoke test 1: Health check + version verification ---\"\nEXPECTED_VERSION=\"${GITHUB_SHA:0:7}\"\nHEALTH=\"\"\nfor i in $(seq 1 30); do\n HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health) && break\n echo \" Attempt $i/30: not ready yet, waiting 5s...\"\n sleep 5\ndone\nif [ -z \"$HEALTH\" ]; then\n echo \"FAIL: health endpoint unreachable after 150s\"\n exit 1\nfi\necho \"Health OK: $HEALTH\"\n\n# 验证版本号匹配\nACTUAL_VERSION=$(echo \"$HEALTH\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('version','')[:7])\")\nif [ \"$ACTUAL_VERSION\" != \"$EXPECTED_VERSION\" ]; then\n echo \"WARN: version mismatch - expected $EXPECTED_VERSION, got $ACTUAL_VERSION\"\n echo \"Waiting for Watchtower to finish updating...\"\n VERSION_MATCH=0\n for i in $(seq 1 36); do\n sleep 5\n HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health) || continue\n ACTUAL_VERSION=$(echo \"$HEALTH\" | python3 -c \"import sys,json; print(json.load(sys.stdin).get('version','')[:7])\")\n if [ \"$ACTUAL_VERSION\" = \"$EXPECTED_VERSION\" ]; then\n VERSION_MATCH=1\n echo \"Version updated: $ACTUAL_VERSION\"\n break\n fi\n echo \" Attempt $i/36: still $ACTUAL_VERSION, waiting...\"\n done\n if [ $VERSION_MATCH -eq 0 ]; then\n echo \"FAIL: staging version not updated after 3 minutes (still $ACTUAL_VERSION, expected $EXPECTED_VERSION)\"\n exit 1\n fi\nelse\n echo \"Version OK: $ACTUAL_VERSION\"\nfi\n\necho \"--- Smoke test 2: Login API (expect 401) ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 -X POST \\\n https://staging-api.xiaoxiajianji.com/api/v1/auth/login \\\n -H \"Content-Type: application/json\" \\\n -d '{\"email\":\"smoke@test.com\",\"password\":\"wrong\"}')\nif [ \"$HTTP_CODE\" != \"401\" ] && [ \"$HTTP_CODE\" != \"422\" ]; then\n echo \"FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)\"\n exit 1\nfi\necho \"Login API OK: HTTP $HTTP_CODE\"\n\necho \"--- Smoke test 3: API docs endpoint ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 https://staging-api.xiaoxiajianji.com/docs)\nif [ \"$HTTP_CODE\" != \"200\" ]; then\n echo \"FAIL: /docs returned HTTP $HTTP_CODE (expected 200)\"\n exit 1\nfi\necho \"Docs endpoint OK: HTTP $HTTP_CODE\"\n\necho \"--- Smoke test 4: Web frontend ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 https://staging.xiaoxiajianji.com/)\nif [ \"$HTTP_CODE\" != \"200\" ]; then\n echo \"FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)\"\n exit 1\nfi\necho \"Web frontend OK: HTTP $HTTP_CODE\"\n\necho \"\"\necho \"=== All smoke tests passed! ===\"\necho \"Branch: ${GITHUB_REF_NAME}\"\necho \"Commit: ${GITHUB_SHA}\"\necho \"Staging Version: ${ACTUAL_VERSION}\"\n"
run: "set -eu\necho \"Waiting 90s for Watchtower to detect new image and restart containers...\"\nsleep 90\n\necho \"--- Smoke test 1: Health check ---\"\nfor i in $(seq 1 12); do\n HEALTH=$(curl -sf --max-time 10 https://staging-api.xiaoxiajianji.com/health) && break\n echo \" Attempt $i/12: not ready yet, waiting 5s...\"\n sleep 5\ndone\nif [ -z \"$HEALTH\" ]; then\n echo \"FAIL: health endpoint unreachable after 60s\"\n exit 1\nfi\necho \"Health OK: $HEALTH\"\n\necho \"--- Smoke test 2: Login API (expect 401) ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 -X POST \\\n https://staging-api.xiaoxiajianji.com/api/v1/auth/login \\\n -H \"Content-Type: application/json\" \\\n -d '{\"email\":\"smoke@test.com\",\"password\":\"wrong\"}')\nif [ \"$HTTP_CODE\" != \"401\" ] && [ \"$HTTP_CODE\" != \"422\" ]; then\n echo \"FAIL: login returned HTTP $HTTP_CODE (expected 401 or 422)\"\n exit 1\nfi\necho \"Login API OK: HTTP $HTTP_CODE\"\n\necho \"--- Smoke\
\ test 3: API docs endpoint ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 https://staging-api.xiaoxiajianji.com/docs)\nif [ \"$HTTP_CODE\" != \"200\" ]; then\n echo \"FAIL: /docs returned HTTP $HTTP_CODE (expected 200)\"\n exit 1\nfi\necho \"Docs endpoint OK: HTTP $HTTP_CODE\"\n\necho \"--- Smoke test 4: Web frontend ---\"\nHTTP_CODE=$(curl -s -o /dev/null -w \"%{http_code}\" --max-time 10 https://staging.xiaoxiajianji.com/)\nif [ \"$HTTP_CODE\" != \"200\" ]; then\n echo \"FAIL: web frontend returned HTTP $HTTP_CODE (expected 200)\"\n exit 1\nfi\necho \"Web frontend OK: HTTP $HTTP_CODE\"\n\necho \"\"\necho \"=== All smoke tests passed! ===\"\necho \"Branch: ${GITHUB_REF_NAME}\"\necho \"Commit: ${GITHUB_SHA}\"\n"
- name: Job duration summary
if: always()
@@ -752,7 +752,7 @@ jobs:
runs-on:
- saas
- build-farm
timeout-minutes: 30
timeout-minutes: 20
needs:
- validate
- frontend-lint
@@ -827,7 +827,7 @@ jobs:
runs-on:
- saas
- build-farm
timeout-minutes: 40
timeout-minutes: 20
needs:
- validate
- frontend-lint
@@ -877,7 +877,7 @@ jobs:
echo "Building Production Worker image: ${VERSION}"
docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=min" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push .
docker buildx build --build-arg APP_VERSION="${VERSION}" --cache-from "type=registry,ref=${CACHE_REF},ignore-error=true" --cache-to "type=registry,ref=${CACHE_REF},mode=max" -f infra/docker/worker.Dockerfile -t "${REGISTRY}/${IMAGE_NAME}:${VERSION}" --push .
echo "Production Worker image pushed: ${REGISTRY}/${IMAGE_NAME}:${VERSION}"
@@ -902,7 +902,7 @@ jobs:
runs-on:
- saas
- build-farm
timeout-minutes: 30
timeout-minutes: 20
needs:
- validate
- frontend-lint
@@ -984,7 +984,7 @@ jobs:
deploy-production:
name: Deploy Production
runs-on: saas
timeout-minutes: 30
timeout-minutes: 20
if: startsWith(github.ref, 'refs/tags/v')
needs:
- build-production-api
-18
View File
@@ -1,18 +0,0 @@
name: Clean NPM Cache
on:
push:
branches:
- temp-clean-npm-cache
jobs:
clean-cache:
runs-on: saas
steps:
- name: Remove npm cache volume
run: |
echo "Removing xiaoxia-npm-cache volume..."
docker volume rm xiaoxia-npm-cache 2>&1 || echo "Volume not found or already removed"
echo "Done. Current volumes:"
docker volume ls | grep npm || echo "No npm volumes found"
+1 -1
View File
@@ -204,7 +204,7 @@ export const createAsset = async (data: {
/** 更新素材(名称、metadata 等) */
export const updateAsset = async (
assetId: string,
data: { name?: string; metadata?: AssetMetadata },
data: { name?: string; metadata?: Record<string, unknown> },
): Promise<AssetItem> => {
const response = await apiClient.put(`/assets/${assetId}`, data);
return response.data;
+1 -2
View File
@@ -129,8 +129,7 @@ apiClient.interceptors.response.use(
const safeExtractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
+8 -36
View File
@@ -97,30 +97,6 @@ export interface EditPlanConfig {
green_screen_config?: ChromaKeyConfig;
sticker_config?: StickerConfig;
cover_config?: CoverConfig;
/** 前端扩展:关联的素材 ID 列表 */
asset_ids?: string[];
/** 配音 ID */
voice_id?: string;
/** 克隆音色档案 ID */
voice_clone_profile_id?: string;
/** 自定义配音音频 URL */
custom_audio_url?: string;
/** 自定义配音文本 */
custom_text?: string;
/** 视频比例 */
ratio?: string;
/** 视频风格 */
style?: string;
/** 目标时长(秒) */
duration?: number;
/** 是否自动生成字幕 */
auto_subtitles?: boolean;
/** 是否启用 BGM */
bgm?: boolean;
/** 生成数量 */
generate_count?: number;
/** 素材模式 */
material_mode?: string;
}
/** 剪辑计划(后端响应) */
@@ -194,7 +170,7 @@ export interface GenerationStatusResponse {
export interface GeneratedVideo {
id: string;
project_id?: string;
generation_task_id?: string;
generation_task_id: string;
name: string;
file_url: string;
file_size?: number;
@@ -206,8 +182,6 @@ export interface GeneratedVideo {
status: string;
review_status?: string;
download_url?: string;
created_at?: string;
updated_at?: string;
}
/* ============================================================
@@ -252,15 +226,13 @@ export interface GenerateCoverRequest {
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string;
cover: CoverResult;
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string;
asset_id?: string;
frame_time?: number;
thumbnail_url?: string;
cover: {
scheme?: string;
asset_id?: string;
frame_time?: number;
thumbnail_url?: string;
[key: string]: unknown;
};
}
/* ============================================================
+1 -13
View File
@@ -139,23 +139,11 @@ export interface GenerateFromTemplatePayload {
voiceover_duration: number;
}
/** 验证警告详情 */
export interface ValidationWarningDetails {
/** 相关字段名 */
field?: string;
/** 期望值 */
expected?: string | number;
/** 实际值 */
actual?: string | number;
/** 建议值 */
suggested?: string | number;
}
/** 验证/生成响应 */
export interface ValidateWarning {
code: string;
message: string;
details?: ValidationWarningDetails;
details?: Record<string, unknown>;
}
/** 使用模板生成响应 */
+21 -14
View File
@@ -60,26 +60,33 @@ export interface BatchDownloadStatus {
/**
* 将 generation task 数据映射为 ProductItem 格式
*/
function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
function mapTaskToProductItem(
task: GeneratedVideo | Record<string, unknown>,
): ProductItem {
const video = task as GeneratedVideo;
return {
id: task.id,
title: task.name || "未命名视频",
video_url: task.file_url,
thumbnail_url: task.thumbnail_url,
duration_seconds: task.duration,
file_size: task.file_size,
id: video.id,
title: video.name || "未命名视频",
video_url: video.file_url,
thumbnail_url: video.thumbnail_url,
duration_seconds: video.duration,
file_size: video.file_size,
resolution:
task.width && task.height ? `${task.width}x${task.height}` : undefined,
video.width && video.height
? `${video.width}x${video.height}`
: undefined,
status:
task.status === "completed"
video.status === "completed"
? "completed"
: task.status === "failed"
: video.status === "failed"
? "failed"
: "processing",
review_status: task.review_status as ReviewStatus | undefined,
project_id: task.project_id,
created_at: task.created_at,
updated_at: task.updated_at,
review_status: video.review_status as ReviewStatus | undefined,
project_id: video.project_id,
created_at: (task as Record<string, unknown>).created_at as
string | undefined,
updated_at: (task as Record<string, unknown>).updated_at as
string | undefined,
};
}
+2 -14
View File
@@ -8,18 +8,6 @@ import apiClient from "./client";
/* ── 类型定义 ──────────────────────────────────── */
/** TTS 元数据(合成时附带的扩展信息) */
export interface TTSMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 语言 */
language?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** TTS 合成请求参数 */
export interface TTSSynthesizeRequest {
text: string;
@@ -30,7 +18,7 @@ export interface TTSSynthesizeRequest {
voice_model?: string;
voice_clone_profile_id?: string;
format?: string;
metadata?: TTSMetadata;
metadata?: Record<string, unknown>;
}
/** TTS 合成创建响应 */
@@ -61,7 +49,7 @@ export interface TTSJob {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: TTSMetadata | null;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
+2 -14
View File
@@ -36,18 +36,6 @@ export interface CreateVoiceCloneRequest {
/* ── 后端 API 类型 ────────────────────────────────────── */
/** 音色克隆元数据(克隆时附带的扩展信息) */
export interface VoiceCloneMetadata {
/** 语音时长(秒) */
duration?: number;
/** 采样率(Hz */
sample_rate?: number;
/** 音色 ID(克隆完成后分配) */
voice_id?: string;
/** 其他扩展字段 */
[key: string]: unknown;
}
/** 后端克隆档案响应 */
export interface VoiceCloneProfile {
id: string;
@@ -63,7 +51,7 @@ export interface VoiceCloneProfile {
error_message: string | null;
retry_count: number;
max_retries: number;
metadata_: VoiceCloneMetadata | null;
metadata_: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
@@ -92,7 +80,7 @@ export interface CreateVoiceCloneRequestFull {
language?: string;
gender?: string;
max_retries?: number;
metadata_?: VoiceCloneMetadata;
metadata_?: Record<string, unknown>;
}
/* ── 辅助函数 ─────────────────────────────────────────── */
+7 -16
View File
@@ -202,8 +202,8 @@ const GeneratePage: React.FC = () => {
try {
const plan = await getEditPlan(editPlanId);
if (plan.name) setTitle(plan.name);
const cfg = plan.config;
if (cfg?.asset_ids) {
const cfg = plan.config as Record<string, unknown>;
if (cfg && Array.isArray(cfg.asset_ids)) {
setSelectedMaterials(
cfg.asset_ids.filter((v): v is string => typeof v === "string"),
);
@@ -477,13 +477,7 @@ const GeneratePage: React.FC = () => {
setGenerateError(null);
try {
const voiceConfig: Pick<
EditPlanConfig,
| "voice_id"
| "voice_clone_profile_id"
| "custom_audio_url"
| "custom_text"
> = {};
const voiceConfig: Record<string, unknown> = {};
if (voiceMode === "preset") {
voiceConfig.voice_id = selectedVoice || undefined;
} else if (voiceMode === "clone") {
@@ -508,7 +502,7 @@ const GeneratePage: React.FC = () => {
bgm,
generate_count: generateCount,
material_mode: materialMode,
},
} as EditPlanConfig,
total_duration: duration,
source_edit_plan_id: editPlanId || undefined,
});
@@ -567,8 +561,7 @@ const GeneratePage: React.FC = () => {
const safeExtract = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -628,8 +621,7 @@ const GeneratePage: React.FC = () => {
const extractString = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -659,8 +651,7 @@ const GeneratePage: React.FC = () => {
const safeExtractErr = (val: unknown): string => {
if (typeof val === "string") return val;
if (typeof val === "object" && val !== null) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
const obj = val as Record<string, any>;
const obj = val as Record<string, unknown>;
if (typeof obj.message === "string") return obj.message;
if (typeof obj.msg === "string") return obj.msg;
if (typeof obj.detail === "string") return obj.detail;
@@ -99,20 +99,10 @@ const formatDuration = (seconds: number): string => {
return `${m}${s > 0 ? `${s}` : ""}`;
};
/** 配置展示字段(formatConfig 提取通用配置的可读属性) */
interface ConfigDisplayFields {
font_size?: string | number;
font_family?: string;
color?: string;
position?: string;
volume?: string | number;
name?: string;
}
/** 格式化配置对象为可读文本 */
const formatConfig = (config?: object): string => {
if (!config || Object.keys(config).length === 0) return "默认";
const c = config as ConfigDisplayFields;
const c = config as Record<string, unknown>;
const parts: string[] = [];
if (c.font_size) parts.push(`字号: ${c.font_size}`);
if (c.font_family) parts.push(`字体: ${c.font_family}`);
@@ -130,20 +130,12 @@ const mapAssetToMaterial = (asset: AssetItem): VoiceMaterial => {
};
};
/** 配音素材上传元数据(传递给 createAsset 的 metadata */
interface VoiceAssetMetadata {
gender: VoiceGender;
description: string;
duration: number;
[key: string]: unknown;
}
/** 前端表单数据 → 后端 metadata(标签走独立 API,不再写 metadata.style */
const buildMetadata = (data: {
gender: VoiceGender;
description: string;
duration?: number;
}): VoiceAssetMetadata => ({
}): Record<string, unknown> => ({
gender: data.gender,
description: data.description,
duration: data.duration || 0,
+2 -10
View File
@@ -623,20 +623,12 @@ const getAudioDuration = (file: File): Promise<number> =>
audio.src = url;
});
/** 音色上传元数据(传递给 createAsset 的 metadata */
interface VoiceUploadMetadata {
gender?: string;
description?: string;
duration?: number;
[key: string]: unknown;
}
const buildVoiceMetadata = (data: {
gender?: string;
description?: string;
duration?: number;
}): VoiceUploadMetadata => {
const metadata: VoiceUploadMetadata = {};
}): Record<string, unknown> => {
const metadata: Record<string, unknown> = {};
if (data.gender) metadata.gender = data.gender;
if (data.description) metadata.description = data.description;
if (data.duration) metadata.duration = Math.round(data.duration);
+26 -56
View File
@@ -2,7 +2,7 @@
"""
统一CI通知脚本 - 发送飞书卡片通知
支持三种模式: start / success / failure
包含: PR链接、耗时、失败阶段、分支、提交者、Run链接、Runner信息
包含: PR链接、耗时、失败阶段、分支、提交者、Run链接
用法:
NOTIFY_MODE=start JOB_NAME="xxx" python3 scripts/ci_notify.py
@@ -23,12 +23,6 @@
GITHUB_EVENT_NAME - 事件类型 (pull_request / push / ...)
GITHUB_PR_NUMBER - PR编号 (PR事件时)
GITHUB_PR_TITLE - PR标题 (PR事件时)
RUNNER_NAME - Runner名称 (可选,自动获取)
设计原则:
1. 通知失败永远不阻断CI主流程(返回exit code 0
2. 标题包含"CI通知"/"CI告警"关键词,适配飞书webhook关键词校验
3. 卡片信息尽量丰富,方便快速定位问题
"""
import json
@@ -38,7 +32,6 @@ import urllib.request
def get_env(name, default=""):
"""读取环境变量"""
return os.environ.get(name, default)
@@ -55,20 +48,6 @@ def format_duration(seconds_str):
return seconds_str or "未知"
def classify_job(job_name):
"""根据Job名称判断所属阶段"""
name = job_name.lower()
if any(k in name for k in ["validate", "lint", "unit test", "integration test"]):
return "门禁检查"
if any(k in name for k in ["build", "image"]):
return "镜像构建"
if any(k in name for k in ["deploy", "staging", "production"]):
return "部署发布"
if any(k in name for k in ["e2e", "test", "smoke"]):
return "测试验证"
return "其他"
def main() -> int:
webhook = get_env("CI_NOTIFY_WEBHOOK")
if not webhook:
@@ -92,60 +71,49 @@ def main() -> int:
event_name = get_env("GITHUB_EVENT_NAME", "")
pr_number = get_env("GITHUB_PR_NUMBER", "")
pr_title = get_env("GITHUB_PR_TITLE", "")
runner_name = get_env("RUNNER_NAME", "")
run_url = f"https://git.xiaoxiajianji.com/{repo}/actions/runs/{run_id}"
job_stage = classify_job(job_name)
# 根据模式设置标题、状态、颜色
# 注意:标题中必须包含飞书webhook配置的关键词,否则会报"Key Words Not Found"
# 这里加入"CI通知"/"CI告警"关键词提高命中率
if mode == "start":
title = f"🔄 CI通知:{job_name} 开始构建"
title = "🔄 CI构建任务开始"
status = "blue"
button_text = "查看进度"
button_type = "primary"
elif mode == "success":
title = f"✅ CI通知:{job_name} 构建成功"
title = f"✅ CI构建成功 - {job_name}"
status = "green"
button_text = "查看详情"
button_type = "primary"
else: # failure
title = f"❌ CI告警:{job_name} 构建失败"
title = f"❌ CI构建失败 - {job_name}"
status = "red"
button_text = "查看失败日志"
button_type = "danger"
# 构建卡片内容 - 左侧标签+右侧值的结构化展示
fields = []
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**阶段**\n{job_stage}"}})
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**任务**\n{job_name}"}})
# 构建卡片内容(含关键词"CI构建"以通过飞书机器人关键词校验)
content_lines = []
content_lines.append(f"**CI构建任务**: {job_name}")
if mode != "start":
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**耗时**\n{duration}"}})
else:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**状态**\n进行中"}})
if runner_name:
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Runner**\n{runner_name}"}})
content_lines.append(f"**耗时**: {duration}")
if mode == "failure" and failed_step:
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**失败步骤**\n{failed_step}"}})
content_lines.append(f"**失败阶段**: {failed_step}")
# PR/分支信息
# PR信息
if event_name == "pull_request" and pr_number:
pr_url = f"https://git.xiaoxiajianji.com/{repo}/pulls/{pr_number}"
pr_display = f"#{pr_number}"
if pr_title:
pr_display += f" {pr_title[:30]}"
fields.append({"is_short": False, "text": {"tag": "lark_md", "content": f"**PR**\n[{pr_display}]({pr_url})"}})
pr_display += f" {pr_title}"
content_lines.append(f"**PR**: [{pr_display}]({pr_url})")
elif event_name == "push":
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**分支**\n{branch}"}})
content_lines.append(f"**分支**: {branch}")
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交**\n`{commit}`"}})
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**提交者**\n{actor}"}})
fields.append({"is_short": True, "text": {"tag": "lark_md", "content": f"**Run ID**\n{run_id}"}})
content_lines.append(f"**提交**: `{commit}`")
content_lines.append(f"**提交者**: {actor}")
content_lines.append(f"**Run ID**: {run_id}")
payload = {
"msg_type": "interactive",
@@ -160,7 +128,10 @@ def main() -> int:
"elements": [
{
"tag": "div",
"fields": fields,
"text": {
"tag": "lark_md",
"content": "\n".join(content_lines),
},
},
{
"tag": "action",
@@ -187,20 +158,19 @@ def main() -> int:
try:
with urllib.request.urlopen(req, timeout=10) as resp:
resp_body = resp.read().decode("utf-8")
print(f"通知已发送 ({mode})")
# 飞书返回code=0表示成功
try:
result = json.loads(resp_body)
if result.get("code", 0) != 0:
print(f"通知发送告警: 飞书返回错误 - {result.get('msg', resp_body)}", file=sys.stderr)
print(f"通知已发送 ({mode}) - 飞书返回非0,但不阻断CI流程")
else:
print(f"通知已发送 ({mode})")
print(f"飞书返回错误: {result.get('msg', resp_body)}", file=sys.stderr)
return 1
except json.JSONDecodeError:
print(f"通知已发送 ({mode})")
pass
except Exception as e:
print(f"通知发送告警: {e}", file=sys.stderr)
print(f"通知发送失败: {e}", file=sys.stderr)
return 1
# 通知无论成功失败都不阻断CI主流程,统一返回0
return 0