diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4e92f99 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.git +.github +.next +**/.next +node_modules +**/node_modules +.env +.env.* +!.env.example +!**/.env.example +*.log +coverage +playwright-report +test-results +apps/mobile/ios +apps/mobile/android +apps/mobile/.expo +docs diff --git a/.github/workflows/deploy-tencent.yml b/.github/workflows/deploy-tencent.yml index 9b426b4..83caa38 100644 --- a/.github/workflows/deploy-tencent.yml +++ b/.github/workflows/deploy-tencent.yml @@ -1,9 +1,21 @@ -name: Deploy Tencent +name: Deploy Tencent Docker permissions: contents: read on: + pull_request: + paths: + - .dockerignore + - Dockerfile + - deploy/** + - apps/web/next.config.ts + - apps/web/app/api/health/** + - package.json + - package-lock.json + - apps/web/package.json + - packages/** + - .github/workflows/deploy-tencent.yml push: branches: - main @@ -11,62 +23,97 @@ on: workflow_dispatch: concurrency: - group: deploy-tencent-${{ github.ref }} + group: deploy-tencent-docker-${{ github.ref }} cancel-in-progress: false +env: + IMAGE_NAME: meteorvoice-web + jobs: + build: + name: Validate deployment image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + if: github.event_name == 'pull_request' + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + platforms: linux/amd64 + tags: ${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Upload validated source artifact + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: meteorvoice-source-${{ github.sha }} + path: | + . + !.git + !node_modules + !apps/**/node_modules + !packages/**/node_modules + !apps/**/.next + retention-days: 7 + deploy: - name: Deploy Web/API + name: Deploy Web/API container + if: github.event_name != 'pull_request' + needs: build runs-on: [self-hosted, linux, x64, tencent, meteorvoice] environment: tencent steps: + - name: Download validated source artifact + uses: actions/download-artifact@v4 + with: + name: meteorvoice-source-${{ github.sha }} + path: source + - name: Resolve target id: target shell: bash run: | if [ "${GITHUB_REF_NAME}" = "release" ]; then - echo "app_dir=/srv/meteorvoice-release" >> "$GITHUB_OUTPUT" - echo "pm2_name=meteorvoice-release" >> "$GITHUB_OUTPUT" + echo "environment=production" >> "$GITHUB_OUTPUT" echo "port=3100" >> "$GITHUB_OUTPUT" + echo "shadow_port=3110" >> "$GITHUB_OUTPUT" + echo "pm2_name=meteorvoice-release" >> "$GITHUB_OUTPUT" + echo "public_url=https://meteorvoice.jcmeteor.com/" >> "$GITHUB_OUTPUT" else - echo "app_dir=/srv/meteorvoice" >> "$GITHUB_OUTPUT" - echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT" + echo "environment=preview" >> "$GITHUB_OUTPUT" echo "port=3101" >> "$GITHUB_OUTPUT" + echo "shadow_port=3111" >> "$GITHUB_OUTPUT" + echo "pm2_name=meteorvoice" >> "$GITHUB_OUTPUT" + echo "public_url=https://mv-pre.jcmeteor.com/" >> "$GITHUB_OUTPUT" fi + - name: Build image from artifact + run: >- + docker build + --build-arg NPM_REGISTRY=https://registry.npmmirror.com + --tag "meteorvoice-web:${GITHUB_SHA}" + --file source/Dockerfile + source + - name: Deploy - shell: bash + env: + APP_NAME: meteorvoice + ENVIRONMENT: ${{ steps.target.outputs.environment }} + HOST_PORT: ${{ steps.target.outputs.port }} + IMAGE_URI: meteorvoice-web:${{ github.sha }} + PM2_NAME: ${{ steps.target.outputs.pm2_name }} + PUBLIC_URL: ${{ steps.target.outputs.public_url }} + RUNTIME_ENV_FILE: /etc/meteorvoice/meteorvoice.env + SHADOW_PORT: ${{ steps.target.outputs.shadow_port }} run: | - retry() { - local attempts="$1" - local delay="$2" - shift 2 - - local attempt=1 - until "$@"; do - if [ "$attempt" -ge "$attempts" ]; then - return 1 - fi - - echo "Command failed. Retrying in ${delay}s (${attempt}/${attempts})..." - sleep "$delay" - attempt=$((attempt + 1)) - done - } - - cd '${{ steps.target.outputs.app_dir }}' - retry 5 15 timeout 180s git fetch origin "$GITHUB_REF_NAME" - git checkout "$GITHUB_REF_NAME" - git reset --hard "origin/$GITHUB_REF_NAME" - retry 3 15 npm ci - set -a - . /etc/meteorvoice/meteorvoice.env - set +a - retry 2 15 npm run build - unset RUNNER_TRACKING_ID - PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \ - pm2 restart '${{ steps.target.outputs.pm2_name }}' --update-env || \ - PORT='${{ steps.target.outputs.port }}' HOSTNAME=127.0.0.1 \ - pm2 start npm --name '${{ steps.target.outputs.pm2_name }}' -- run start --workspace @meteorvoice/web -- --hostname 127.0.0.1 --port '${{ steps.target.outputs.port }}' - pm2 save - retry 12 5 curl -fsS 'http://127.0.0.1:${{ steps.target.outputs.port }}/' >/dev/null + chmod +x source/deploy/deploy-container.sh + source/deploy/deploy-container.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c658ef4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,41 @@ +FROM node:24-bookworm-slim AS dependencies + +WORKDIR /app + +ARG NPM_REGISTRY=https://registry.npmjs.org + +COPY package.json package-lock.json ./ +COPY apps/web/package.json apps/web/package.json +COPY apps/mobile/package.json apps/mobile/package.json +COPY packages/api-client/package.json packages/api-client/package.json +COPY packages/session-core/package.json packages/session-core/package.json +COPY packages/shared/package.json packages/shared/package.json + +RUN npm ci --registry="${NPM_REGISTRY}" + +FROM dependencies AS builder + +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:24-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 + +WORKDIR /app + +RUN groupadd --system --gid 1001 nodejs \ + && useradd --system --uid 1001 --gid nodejs nextjs + +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static +COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public + +USER nextjs +EXPOSE 3000 + +CMD ["node", "apps/web/server.js"] diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 0926705..dff7cee 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "MeteorVoice", "slug": "meteorvoice-mobile", "owner": "junchenmeteors-team", - "version": "1.4.1", + "version": "1.4.2", "orientation": "portrait", "scheme": "meteorvoice", "userInterfaceStyle": "automatic", @@ -11,7 +11,7 @@ "ios": { "supportsTablet": true, "bundleIdentifier": "com.jcmeteor.meteorvoice", - "buildNumber": "2026071102", + "buildNumber": "2026071103", "deploymentTarget": "18.0", "infoPlist": { "NSMicrophoneUsageDescription": "MeteorVoice uses the microphone for voice practice sessions.", @@ -26,7 +26,7 @@ }, "android": { "package": "com.jcmeteor.meteorvoice", - "versionCode": 2026071102, + "versionCode": 2026071103, "permissions": [ "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", diff --git a/apps/mobile/app/(tabs)/home.tsx b/apps/mobile/app/(tabs)/home.tsx index 0f226cd..8ba27dd 100644 --- a/apps/mobile/app/(tabs)/home.tsx +++ b/apps/mobile/app/(tabs)/home.tsx @@ -5,8 +5,6 @@ import { useRouter } from 'expo-router' -import { scenarios } from '@meteorvoice/shared' - import { HomeScreen } from '../../src/screens/HomeScreen' import { useSession } from '../../src/SessionContext' @@ -18,8 +16,6 @@ export default function HomeTab() { router.replace('/(tabs)/session')} /> ) diff --git a/apps/mobile/app/(tabs)/session.tsx b/apps/mobile/app/(tabs)/session.tsx index b2c1e6b..f379de5 100644 --- a/apps/mobile/app/(tabs)/session.tsx +++ b/apps/mobile/app/(tabs)/session.tsx @@ -19,13 +19,13 @@ import { SessionScreen } from '../../src/screens/SessionScreen' import { useSession } from '../../src/SessionContext' export default function SessionTab() { - const { tr, locale, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion } = useSession() + const { tr, locale, availableScenarios, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion } = useSession() const { scenario, accent } = useMemo(() => { - const s = scenarios.find(x => x.key === selectedScenarioKey) ?? scenarios[0] + const s = availableScenarios.find(x => x.key === selectedScenarioKey) ?? availableScenarios[0] ?? scenarios[0] const a = accentProfiles.find(x => x.key === selectedAccentKey) ?? accentProfiles[0] return { scenario: s, accent: a } - }, [selectedScenarioKey, selectedAccentKey]) + }, [availableScenarios, selectedScenarioKey, selectedAccentKey]) return ( (scenarios) const [selectedAccentKey, setSelectedAccentKey] = useState('american') const [scenarioSwitching, setScenarioSwitching] = useState(false) const [apiSessionId] = useState(null) @@ -232,8 +235,29 @@ function AppInner({ children }: { children?: React.ReactNode }) { return () => { cancelled = true } }, [api, applyTtsPreferences, auth.state, logMetric]) + useEffect(() => { + let cancelled = false + void api.listScenarios(locale) + .then(result => { + if (!cancelled) setAvailableScenarios(resolveRuntimeScenarios(result.scenarios)) + }) + .catch(error => { + if (!cancelled) setAvailableScenarios(scenarios) + logMetric('mobile_scenarios_load_error', { + message: error instanceof Error ? error.message : 'unknown', + }) + }) + return () => { cancelled = true } + }, [api, auth.state, locale, logMetric]) + // ─── Derived Values / 派生值 ─── - const scenario = useMemo(() => scenarios.find(s => s.key === selectedScenarioKey) ?? scenarios[0], [selectedScenarioKey]) + const scenario = useMemo( + () => availableScenarios.find(s => s.key === selectedScenarioKey) + ?? scenarios.find(s => s.key === selectedScenarioKey) + ?? availableScenarios[0] + ?? scenarios[0], + [availableScenarios, selectedScenarioKey], + ) const accent = useMemo(() => accentProfiles.find(a => a.key === selectedAccentKey) ?? accentProfiles[0], [selectedAccentKey]) // Voice profile accent override (for SettingsScreen voice selection) @@ -868,6 +892,7 @@ function AppInner({ children }: { children?: React.ReactNode }) { // ─── SessionContext Value / 会话上下文值 ─── const sessionContext = useMemo(() => ({ appVersion, applyTtsPreferences, auth, defaultApiBaseUrl, getAuthHeaders, handleUnauthorized, signOut, + availableScenarios, snapshot, messages, corrections: correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, @@ -877,7 +902,7 @@ function AppInner({ children }: { children?: React.ReactNode }) { audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitText: submitTurn, clearAudio, - }), [applyTtsPreferences, auth, clearAudio, getAuthHeaders, handleUnauthorized, signOut, snapshot, messages, correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, ttsProvider, ttsVoiceId, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion, audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitTurn, playbackQueue, setSelectedAccentKey, setTtsProvider, setTtsVoiceId, setTtsSpeed]) + }), [applyTtsPreferences, auth, availableScenarios, clearAudio, getAuthHeaders, handleUnauthorized, signOut, snapshot, messages, correctionHistory, summary, isSessionActive, status, busy, scenarioSwitching, locale, tr, ttsProvider, ttsVoiceId, selectedScenarioKey, selectedAccentKey, voiceProfileAccentLabel, voiceProfileAccentRegion, audioUrl, api, startSession, endSession, playCorrection, selectScenario, setLocale, submitTurn, playbackQueue, setSelectedAccentKey, setTtsProvider, setTtsVoiceId, setTtsSpeed]) // ─── Tab Selection / 标签选择 ─── const selectTab = useCallback((tab: Tab) => { diff --git a/apps/mobile/src/AppShell.tsx b/apps/mobile/src/AppShell.tsx index 04903cc..8776a5d 100644 --- a/apps/mobile/src/AppShell.tsx +++ b/apps/mobile/src/AppShell.tsx @@ -17,9 +17,6 @@ import type { Locale, TranslateFn, } from '@meteorvoice/shared' -import { - scenarios, -} from '@meteorvoice/shared' import type { SessionContextValue } from './SessionContext' import type { MobileAuthState } from './mobileAuth' @@ -132,7 +129,7 @@ export function AppShell({ - {activeTab === 'home' && setActiveTab('session')} />} + {activeTab === 'home' && setActiveTab('session')} />} {activeTab === 'session' && } {activeTab === 'history' && } {activeTab === 'settings' && } diff --git a/apps/mobile/src/SessionContext.tsx b/apps/mobile/src/SessionContext.tsx index 5302649..2cebc48 100644 --- a/apps/mobile/src/SessionContext.tsx +++ b/apps/mobile/src/SessionContext.tsx @@ -22,6 +22,7 @@ import type { ConversationMessage, ConversationResponse, Locale, + Scenario, TranslateFn, } from '@meteorvoice/shared' import type { MobileAuthState } from './mobileAuth' @@ -32,6 +33,7 @@ export interface SessionContextValue { applyTtsPreferences: (preferences: PreferencesResponse) => void auth: MobileAuthState audioUrl: string | null + availableScenarios: Scenario[] busy: boolean clearAudio: () => void corrections: ConversationResponse['corrections'] diff --git a/apps/mobile/src/runtimeScenarios.ts b/apps/mobile/src/runtimeScenarios.ts new file mode 100644 index 0000000..5d7fcfd --- /dev/null +++ b/apps/mobile/src/runtimeScenarios.ts @@ -0,0 +1,9 @@ +/** + * Mobile runtime scenario fallback. / 移动端运行时场景回退。 + */ +import type { Scenario } from '@meteorvoice/shared' +import { scenarios } from '@meteorvoice/shared' + +export function resolveRuntimeScenarios(remote: readonly Scenario[] | null | undefined): Scenario[] { + return remote?.length ? [...remote] : scenarios +} diff --git a/apps/mobile/src/screens/HistoryScreen.tsx b/apps/mobile/src/screens/HistoryScreen.tsx index b78a37f..513e4af 100644 --- a/apps/mobile/src/screens/HistoryScreen.tsx +++ b/apps/mobile/src/screens/HistoryScreen.tsx @@ -17,6 +17,7 @@ import { import type { Locale, + Scenario, TranslateFn, } from '@meteorvoice/shared' import type { @@ -27,12 +28,12 @@ import { accentProfiles, getAccentLabel, getScenarioLabel, - scenarios, } from '@meteorvoice/shared' import { useHistoryScreenState } from '../hooks/useHistoryScreenState' import type { HistoryAuthState } from '../historyRefresh' import { ContentState } from '../components/ContentState' +import { useSession } from '../SessionContext' import { useTheme } from '../ThemeProvider' interface Props { @@ -45,8 +46,8 @@ interface Props { refreshKey: number } -function scenarioLabel(entry: HistorySession, locale: Locale) { - const s = scenarios.find(x => x.key === (entry.scenario_key ?? entry.scenario)) +function scenarioLabel(entry: HistorySession, locale: Locale, availableScenarios: Scenario[]) { + const s = availableScenarios.find(x => x.key === (entry.scenario_key ?? entry.scenario)) return s ? getScenarioLabel(s, locale) : entry.scenario } @@ -56,6 +57,7 @@ function accentLabel(entry: HistorySession, locale: Locale) { } export function HistoryScreen({ tr, locale, api, authState, authUserId, handleUnauthorized, refreshKey }: Props) { + const { availableScenarios } = useSession() const { C } = useTheme() const { deleteSession, @@ -141,7 +143,7 @@ export function HistoryScreen({ tr, locale, api, authState, authUserId, handleUn setFilterScenario(null)} style={[styles.filterChip, filterScenario === null && styles.filterChipActive]}> {tr('history.filter_all')} - {scenarios.map(s => ( + {availableScenarios.map(s => ( setFilterScenario(s.key)} style={[styles.filterChip, filterScenario === s.key && styles.filterChipActive]}> {s.icon} {getScenarioLabel(s, locale)} @@ -174,7 +176,7 @@ export function HistoryScreen({ tr, locale, api, authState, authUserId, handleUn > - {scenarioLabel(item, locale)} + {scenarioLabel(item, locale, availableScenarios)} {item.date} · {accentLabel(item, locale)} diff --git a/apps/mobile/src/screens/HomeScreen.tsx b/apps/mobile/src/screens/HomeScreen.tsx index dec3e14..656e281 100644 --- a/apps/mobile/src/screens/HomeScreen.tsx +++ b/apps/mobile/src/screens/HomeScreen.tsx @@ -17,7 +17,6 @@ import type { Locale, TranslateFn, } from '@meteorvoice/shared' -import type { scenarios as ScenariosType } from '@meteorvoice/shared' import { getDifficultyLabel, getScenarioDescription, @@ -27,20 +26,17 @@ import { import { useSession } from '../SessionContext' import { useTheme } from '../ThemeProvider' -type Scenario = (typeof ScenariosType)[number] - interface Props { tr: TranslateFn locale: Locale - scenarios: Scenario[] onGoToSession: () => void } export function HomeScreen({ - tr, locale, scenarios, + tr, locale, onGoToSession, }: Props) { - const { selectedScenarioKey, isSessionActive, selectScenario, scenarioSwitching } = useSession() + const { availableScenarios, selectedScenarioKey, isSessionActive, selectScenario, scenarioSwitching } = useSession() const { C } = useTheme() async function handleScenario(key: string) { const shouldNavigate = await selectScenario(key) @@ -88,7 +84,7 @@ export function HomeScreen({ {tr('home.subtitle')} item.key} numColumns={2} columnWrapperStyle={styles.row} diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts new file mode 100644 index 0000000..773e028 --- /dev/null +++ b/apps/web/app/api/health/route.ts @@ -0,0 +1,8 @@ +/** + * Process-local health endpoint used by Docker and deployment checks. + */ +import { NextResponse } from 'next/server' + +export function GET() { + return NextResponse.json({ service: 'meteorvoice-web', status: 'ok' }) +} diff --git a/apps/web/app/api/scenarios/route.ts b/apps/web/app/api/scenarios/route.ts index c14d74e..f0b3dcb 100644 --- a/apps/web/app/api/scenarios/route.ts +++ b/apps/web/app/api/scenarios/route.ts @@ -5,7 +5,6 @@ import { getScenarioDescription, getScenarioLabel, normalizeLocale, - scenarios, } from '@meteorvoice/shared' import { @@ -13,6 +12,7 @@ import { jsonApiResult, jsonServerError, } from '@/lib/server/http' +import { listConfiguredScenarios } from '@/lib/server/scenarios' export async function GET(request: Request) { try { @@ -20,9 +20,10 @@ export async function GET(request: Request) { if (guard) return jsonApiResult(guard) const url = new URL(request.url) const locale = normalizeLocale(url.searchParams.get('locale')) + const configuredScenarios = await listConfiguredScenarios() return jsonApiResult({ - scenarios: scenarios.map(scenario => ({ + scenarios: configuredScenarios.map(scenario => ({ ...scenario, label: getScenarioLabel(scenario, locale), localized_description: getScenarioDescription(scenario, locale), @@ -32,4 +33,3 @@ export async function GET(request: Request) { return jsonServerError(error, 'Failed to load scenarios') } } - diff --git a/apps/web/lib/server/scenarios.ts b/apps/web/lib/server/scenarios.ts new file mode 100644 index 0000000..1a5293e --- /dev/null +++ b/apps/web/lib/server/scenarios.ts @@ -0,0 +1,70 @@ +/** + * Runtime scenario configuration backed by Supabase. / Supabase 驱动的运行时场景配置。 + */ +import type { Scenario } from '@meteorvoice/shared' +import { scenarios } from '@meteorvoice/shared' + +import { createClient } from '@/lib/supabase/server' + +export interface ScenarioRow { + key: string + name: string + name_zh: string | null + description: string | null + description_zh: string | null + difficulty: string + icon: string | null +} + +const legacyIcons: Record = { + briefcase: '💼', + building: '🏢', + coffee: '☕', + plane: '✈️', + utensils: '🍽️', +} + +function normalizeDifficulty(value: string): Scenario['difficulty'] { + if (value === 'intermediate' || value === 'advanced') return value + return 'beginner' +} + +export function mapScenarioRow(row: ScenarioRow): Scenario { + const description = row.description?.trim() || row.name + + return { + key: row.key, + name: row.name, + description, + labels: { + en: row.name, + zh: row.name_zh?.trim() || row.name, + }, + descriptions: { + en: description, + zh: row.description_zh?.trim() || description, + }, + difficulty: normalizeDifficulty(row.difficulty), + icon: legacyIcons[row.icon ?? ''] ?? (row.icon?.trim() || '💬'), + } +} + +export function resolveConfiguredScenarios(rows: ScenarioRow[] | null | undefined): Scenario[] { + if (!rows?.length) return scenarios + return rows.map(mapScenarioRow) +} + +export async function listConfiguredScenarios(): Promise { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) return scenarios + + const { data, error } = await supabase + .from('scenarios') + .select('key,name,name_zh,description,description_zh,difficulty,icon') + .eq('enabled', true) + .order('created_at', { ascending: true }) + + if (error) return scenarios + return resolveConfiguredScenarios(data) +} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 19e8d08..3727846 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from "next"; import path from "path"; const nextConfig: NextConfig = { + output: "standalone", transpilePackages: [ "@meteorvoice/api-client", "@meteorvoice/session-core", diff --git a/apps/web/package.json b/apps/web/package.json index 5125878..aae58ea 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@meteorvoice/web", - "version": "1.4.1", + "version": "1.4.2", "private": true, "scripts": { "dev": "next dev", diff --git a/deploy/deploy-container.sh b/deploy/deploy-container.sh new file mode 100644 index 0000000..212f0c2 --- /dev/null +++ b/deploy/deploy-container.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +required=(APP_NAME ENVIRONMENT IMAGE_URI HOST_PORT SHADOW_PORT RUNTIME_ENV_FILE PM2_NAME PUBLIC_URL) +for name in "${required[@]}"; do + if [ -z "${!name:-}" ]; then + echo "Missing required environment variable: ${name}" >&2 + exit 1 + fi +done + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +deploy_dir="/srv/containers/${APP_NAME}/${ENVIRONMENT}" +compose_file="${deploy_dir}/docker-compose.yml" +deployment_env="${deploy_dir}/deployment.env" +project_name="${APP_NAME}-${ENVIRONMENT}" + +mkdir -p "${deploy_dir}" +install -m 0644 "${script_dir}/docker-compose.yml" "${compose_file}" + +write_deployment_env() { + local file="$1" + local image="$2" + local port="$3" + { + printf 'IMAGE_URI=%s\n' "${image}" + printf 'HOST_PORT=%s\n' "${port}" + printf 'RUNTIME_ENV_FILE=%s\n' "${RUNTIME_ENV_FILE}" + } > "${file}" + chmod 0600 "${file}" +} + +compose() { + local project="$1" + local env_file="$2" + shift 2 + env -u IMAGE_URI -u HOST_PORT -u RUNTIME_ENV_FILE \ + docker compose --project-name "${project}" --env-file "${env_file}" --file "${compose_file}" "$@" +} + +wait_for_health() { + local port="$1" + local public_url="${2:-}" + local attempt + for attempt in $(seq 1 24); do + if curl -fsS --max-time 5 "http://127.0.0.1:${port}/api/health" >/dev/null; then + if [ -z "${public_url}" ] || curl -fsS --max-time 10 "${public_url}" >/dev/null; then + return 0 + fi + fi + sleep 5 + done + return 1 +} + +current_container="$(compose "${project_name}" "${deployment_env}" ps -q web 2>/dev/null || true)" +previous_image="" +if [ -n "${current_container}" ]; then + previous_image="$(docker inspect --format '{{.Config.Image}}' "${current_container}")" +fi + +if ! docker image inspect "${IMAGE_URI}" >/dev/null 2>&1; then + echo "Image is not loaded: ${IMAGE_URI}" >&2 + exit 1 +fi + +if [ -z "${current_container}" ] && pm2 describe "${PM2_NAME}" >/dev/null 2>&1; then + shadow_env="$(mktemp)" + shadow_project="${project_name}-shadow" + write_deployment_env "${shadow_env}" "${IMAGE_URI}" "${SHADOW_PORT}" + trap 'compose "${shadow_project}" "${shadow_env}" down --remove-orphans >/dev/null 2>&1 || true; rm -f "${shadow_env}"' EXIT + + compose "${shadow_project}" "${shadow_env}" up -d --wait --wait-timeout 180 + wait_for_health "${SHADOW_PORT}" + + pm2 stop "${PM2_NAME}" + write_deployment_env "${deployment_env}" "${IMAGE_URI}" "${HOST_PORT}" + if ! compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 \ + || ! wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"; then + compose "${project_name}" "${deployment_env}" down --remove-orphans || true + pm2 restart "${PM2_NAME}" --update-env + wait_for_health "${HOST_PORT}" "${PUBLIC_URL}" + exit 1 + fi + + compose "${shadow_project}" "${shadow_env}" down --remove-orphans + rm -f "${shadow_env}" + trap - EXIT + pm2 save +else + write_deployment_env "${deployment_env}" "${IMAGE_URI}" "${HOST_PORT}" + if ! compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 \ + || ! wait_for_health "${HOST_PORT}" "${PUBLIC_URL}"; then + if [ -n "${previous_image}" ]; then + echo "Deployment failed; restoring ${previous_image}" >&2 + write_deployment_env "${deployment_env}" "${previous_image}" "${HOST_PORT}" + compose "${project_name}" "${deployment_env}" up -d --wait --wait-timeout 180 + wait_for_health "${HOST_PORT}" "${PUBLIC_URL}" + fi + exit 1 + fi +fi + +printf '%s\n' "${IMAGE_URI}" > "${deploy_dir}/current-image" +printf 'Deployed %s to %s (%s)\n' "${IMAGE_URI}" "${project_name}" "${PUBLIC_URL}" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..c963897 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,29 @@ +services: + web: + image: ${IMAGE_URI:?IMAGE_URI is required} + init: true + restart: unless-stopped + env_file: + - ${RUNTIME_ENV_FILE:?RUNTIME_ENV_FILE is required} + environment: + HOSTNAME: 0.0.0.0 + NODE_ENV: production + PORT: 3000 + ports: + - 127.0.0.1:${HOST_PORT:?HOST_PORT is required}:3000 + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + mem_limit: 768m + logging: + driver: json-file + options: + max-size: 10m + max-file: "3" diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 5f5d7ef..dffb8ec 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -2,6 +2,8 @@ 本文档记录 MeteorVoice monorepo 迁移后的 Web 部署方式。当前 Web app 位于 `apps/web`,Mobile 位于 `apps/mobile`,共享包位于 `packages/*`。 +腾讯云 Web/API 的 Docker + 源码 Artifact 部署及 PM2 迁移步骤见 `docs/tencent-docker-deployment.md` 和 `docs/tencent-docker-deployment.zh-CN.md`。该文档在迁移验收完成前属于目标设计,不代表服务器已经切换到 Docker。 + ## Vercel 推荐配置 当前推荐让 Vercel 仍从仓库根目录安装和构建,因为 npm workspaces、`file:` 本地包和 lockfile 都在根目录。 diff --git a/docs/index.md b/docs/index.md index f3a056b..18de480 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,6 +63,8 @@ - v1.3.0 release notes,覆盖 ASR provider 层、mobile 语音稳定性、运行时反馈、API 防护和验证命令。 - `docs/deployment-runbook.md` - Monorepo 迁移后的 Vercel 部署、分支职责、环境变量和发布/回滚流程。 +- `docs/tencent-docker-deployment.md` / `docs/tencent-docker-deployment.zh-CN.md` + - 腾讯云 Web/API Docker + 源码 Artifact 部署架构、PM2 平滑迁移、健康检查和回滚手册。 ## 已完成计划 diff --git a/docs/releases/v1.4.2.md b/docs/releases/v1.4.2.md new file mode 100644 index 0000000..e4b0a4b --- /dev/null +++ b/docs/releases/v1.4.2.md @@ -0,0 +1,30 @@ +# Release Notes + +Release focus: production promotion for MeteorVoice 1.4.2. + +## Highlights + +- Promoted validated main branch changes to the production release branch. +- Updated Web and mobile package versions to `1.4.2`. +- Published GitHub Release tag `v1.4.2`. + +## Deployment + +- Production branch: `release` +- Preview branch: `main` +- Production URL: `https://meteorvoice.jcmeteor.com/` +- Preview URL: `https://mv-pre.jcmeteor.com/` + +## Versioning + +- Web version: `1.4.2` +- Mobile version: `1.4.2` +- iOS build number: `2026071103` +- Android version code: `2026071103` +- Release tag: `v1.4.2` + +## Validation + +```bash +npm test +``` diff --git a/docs/supabase-setup.md b/docs/supabase-setup.md index 5af995b..bba68cd 100644 --- a/docs/supabase-setup.md +++ b/docs/supabase-setup.md @@ -59,6 +59,18 @@ This gives you a single login surface with two formal account types: - Coach voice catalog rows are stored on `tts_voice_profiles` and readable by anon/authenticated clients through the server API. - Accent profiles and scenarios are readable by authenticated users. +## Scenario configuration + +The mobile app loads enabled rows from the `scenarios` table after sign-in. Update +`name`, `name_zh`, `description`, `description_zh`, `difficulty`, `icon`, or +`enabled` in the Supabase Table Editor to change the practice catalog without an +app rebuild. Keep at least one row enabled. `icon` may be an emoji; the original +seed values (`briefcase`, `plane`, `coffee`, `utensils`, and `building`) are also +mapped to their emoji equivalents. + +Signed-out clients and failed configuration requests use the shared built-in +catalog so the home screen remains available offline. + ## Coach Voice Profiles `tts_voice_profiles` is the editable catalog for all provider voices. Insert one row per voice. Use `provider_voice_id` for the value required by the provider API, and use `display_name`, `display_name_zh`, `gender`, `style`, `quality_tier`, `status`, and `expires_at` for UI and availability. diff --git a/docs/tencent-docker-deployment.md b/docs/tencent-docker-deployment.md new file mode 100644 index 0000000..4f8d835 --- /dev/null +++ b/docs/tencent-docker-deployment.md @@ -0,0 +1,111 @@ +# Tencent Docker Deployment + +This runbook defines the target Docker deployment for the MeteorVoice Web/API on the existing Tencent server. It covers the server component only; the iOS app, hosted Supabase project, and host Nginx remain outside Docker. + +> Status: target design. The active Tencent deployment still uses PM2 until the migration acceptance checklist in this document is complete. + +## Environment mapping + +| Branch | Environment | Domain | Host port | Compose project | Runtime env | +| --- | --- | --- | ---: | --- | --- | +| `main` | Preview | `mv-pre.jcmeteor.com` / `mv-pre-cn.jcmeteor.com` | `3101` | `meteorvoice-preview` | `/etc/meteorvoice/meteorvoice.env` | +| `release` | Production | `meteorvoice.jcmeteor.com` / `mv-cn.jcmeteor.com` | `3100` | `meteorvoice-production` | `/etc/meteorvoice/meteorvoice.env` | + +Nginx MUST continue binding public ports 80/443. Containers MUST publish only to `127.0.0.1`. + +## Target delivery flow + +1. A GitHub-hosted runner checks out the requested commit. +2. CI runs lint, tests, mobile typecheck, and the Web production build. +3. CI validates the Docker build on pull requests and uploads compact source as a GitHub Actions artifact after merge. +4. The image is tagged with an immutable commit SHA. Branch and release tags MAY be added as aliases, but deployments MUST resolve to the SHA tag. +5. The repository-specific Tencent runner builds from the artifact with the server-side Docker layer cache and updates only the matching Compose project. +6. The runner waits for the container health check and verifies the public domain. +7. A failed health check MUST restore the previous image SHA. + +The server MUST NOT run `git fetch`, `npm ci`, or `next build` after migration. + +## Image contract + +- Image tag: `meteorvoice-web:`. +- Build context: repository root, because the Web app consumes npm workspaces under `packages/*`. +- Next.js MUST use `output: 'standalone'`. +- The runtime stage MUST contain only the standalone server, static assets, and required public files. +- The runtime process MUST run as a non-root user. +- Secrets MUST NOT be copied or passed as Docker build arguments. +- `.dockerignore` MUST exclude `.git`, `.env*`, local build output, logs, and mobile/native build artifacts. + +## Configuration and secrets + +Real provider credentials remain in `/etc/meteorvoice/meteorvoice.env`, owned by root and readable only by the deployment/runtime account as required. Compose injects the file at container startup. + +The self-hosted runner downloads the validated source artifact, builds with the server-side Docker layer cache, and deploys it. It does not pull the Git repository and requires no container-registry password. Xunfei, DeepSeek, Supabase service-role, and other application secrets are not included in the source artifact or image. + +## Compose requirements + +Preview and production MUST use separate Compose project names, containers, and networks. Each service MUST define: + +- `restart: unless-stopped`; +- a health check for the Web/API process; +- JSON log rotation (`max-size: 10m`, `max-file: 3`); +- a memory limit appropriate for the 3.6 GiB host; +- an immutable image SHA; +- `127.0.0.1:3101` or `127.0.0.1:3100` host binding. + +Deployment metadata MAY live under `/srv/containers/meteorvoice/{preview,production}`. Application secrets MUST stay under `/etc/meteorvoice`. + +## First migration from PM2 + +Migrate one environment at a time, preview before production. + +1. Record the current Git commit, PM2 process, Nginx configuration, and public health result. +2. Build and push the candidate image without changing the server runtime. +3. Start a shadow container on an unused localhost port and verify Web pages plus `/api/scenarios`, `/api/chat` request validation, and TTS request validation. +4. Switch only the relevant Nginx upstream to the shadow container and run public-domain checks. +5. Stop only the matching PM2 process. +6. Start the final Compose project on the existing `3101` or `3100` port and return Nginx to that port. +7. Observe logs and health before migrating the next environment. + +Do not run `pm2 kill`. Keep the PM2 definitions and source checkout until both environments have passed the observation window. + +## Routine deployment + +Routine deployment updates only one branch environment: + +1. resolve the new immutable image SHA; +2. record the currently running SHA; +3. download the source artifact and build the image with Docker layer cache; +4. update the matching Compose project from the local immutable image; +5. wait for container health; +6. verify the localhost port and public domain; +7. retain the previous SHA for rollback. + +## Rollback + +For a normal Docker rollback, set the deployment metadata to the previous image SHA and run Compose pull/up again. Verify localhost and public health before closing the incident. + +During the first migration, if Docker cannot serve the environment: + +1. stop the failed Compose project; +2. restore the previous Nginx upstream if it changed; +3. restart only `meteorvoice` or `meteorvoice-release` in PM2; +4. verify the original port and public domain. + +## Acceptance checklist + +- CI builds the same commit that is tagged and deployed. +- No application secret exists in image history, build logs, or artifact metadata. +- Preview and production can deploy and roll back independently. +- Containers bind only to localhost. +- Nginx configuration passes `nginx -t` before reload. +- PM2 rollback is tested before its definitions are removed. +- Preview and production domains return HTTP 200. +- Auth, scenario loading, chat, Xunfei ASR/TTS, and mobile API compatibility are checked. +- Docker logs rotate and old images have a documented retention policy. + +## Related documentation + +- `docs/deployment-runbook.md`: branch and release responsibilities. +- `docs/release-manager.md`: release automation. +- `docs/tts-integration.md`: speech-provider runtime secrets. +- `docs/mobile-local-build-runbook.md`: iOS build and API endpoint behavior. diff --git a/docs/tencent-docker-deployment.zh-CN.md b/docs/tencent-docker-deployment.zh-CN.md new file mode 100644 index 0000000..1ffe0b2 --- /dev/null +++ b/docs/tencent-docker-deployment.zh-CN.md @@ -0,0 +1,111 @@ +# 腾讯云 Docker 部署 + +本文定义 MeteorVoice Web/API 在现有腾讯云服务器上的目标 Docker 部署方式。容器化范围只包括服务端 Web/API;iOS App、托管 Supabase 和宿主机 Nginx 不进入 Docker。 + +> 状态:目标设计。完成本文验收清单之前,腾讯云当前仍使用 PM2 部署。 + +## 环境映射 + +| 分支 | 环境 | 域名 | 宿主机端口 | Compose 项目 | 运行时环境文件 | +| --- | --- | --- | ---: | --- | --- | +| `main` | 预览 | `mv-pre.jcmeteor.com` / `mv-pre-cn.jcmeteor.com` | `3101` | `meteorvoice-preview` | `/etc/meteorvoice/meteorvoice.env` | +| `release` | 生产 | `meteorvoice.jcmeteor.com` / `mv-cn.jcmeteor.com` | `3100` | `meteorvoice-production` | `/etc/meteorvoice/meteorvoice.env` | + +Nginx MUST 继续监听公网 80/443 端口。容器 MUST 只发布到 `127.0.0.1`。 + +## 目标交付流程 + +1. GitHub 托管 Runner 检出目标提交。 +2. CI 执行 lint、测试、Mobile typecheck 和 Web 生产构建。 +3. CI 在 PR 中验证 Docker 构建;合并后将精简源码作为 GitHub Actions Artifact 上传。 +4. 镜像使用不可变 commit SHA 标签;分支和版本号 MAY 作为别名,但部署 MUST 最终解析到 SHA 标签。 +5. MeteorVoice 专属腾讯 Runner 下载 Artifact、将不可变镜像加载到 Docker,并只更新对应 Compose 项目。 +6. Runner 等待容器健康检查并验证公网域名。 +7. 健康检查失败时 MUST 恢复上一镜像 SHA。 + +迁移完成后,服务器 MUST 不再执行 `git fetch`、`npm ci` 或 `next build`。 + +## 镜像契约 + +- 镜像标签:`meteorvoice-web:`。 +- 构建上下文:仓库根目录,因为 Web 依赖 `packages/*` npm workspaces。 +- Next.js MUST 使用 `output: 'standalone'`。 +- 运行阶段 MUST 只包含 standalone server、静态资源和必要 public 文件。 +- 运行进程 MUST 使用非 root 用户。 +- 密钥 MUST NOT 复制进镜像或通过 Docker build argument 传入。 +- `.dockerignore` MUST 排除 `.git`、`.env*`、本地构建输出、日志和 Mobile/native 构建产物。 + +## 配置与密钥 + +真实 provider 凭据继续保存在 `/etc/meteorvoice/meteorvoice.env`,由 root 持有,并按部署和运行所需设置最小读取权限。Compose 在容器启动时注入该文件。 + +自托管 Runner 下载经验证的源码 Artifact,利用服务器 Docker 层缓存构建并部署,不拉取 Git 仓库,也不需要镜像仓库密码。讯飞、DeepSeek、Supabase service-role 等应用密钥不得进入源码 Artifact 或镜像。 + +## Compose 要求 + +预览和生产 MUST 使用不同的 Compose 项目名、容器和网络。每个服务 MUST 定义: + +- `restart: unless-stopped`; +- Web/API 健康检查; +- JSON 日志轮转(`max-size: 10m`、`max-file: 3`); +- 适配当前 3.6 GiB 服务器的内存限制; +- 不可变镜像 SHA; +- `127.0.0.1:3101` 或 `127.0.0.1:3100` 绑定。 + +部署元数据 MAY 放在 `/srv/containers/meteorvoice/{preview,production}`;应用密钥 MUST 保留在 `/etc/meteorvoice`。 + +## 首次从 PM2 迁移 + +每次只迁移一个环境,必须先预览后生产。 + +1. 记录当前 Git commit、PM2 进程、Nginx 配置和公网健康结果。 +2. 构建并推送候选镜像,不改变服务器运行状态。 +3. 在未使用的 localhost 端口启动影子容器,验证 Web 页面以及 `/api/scenarios`、`/api/chat` 参数校验和 TTS 参数校验。 +4. 只将当前环境的 Nginx upstream 切到影子容器并执行公网检查。 +5. 只停止对应 PM2 进程。 +6. 在原 `3101` 或 `3100` 端口启动最终 Compose 项目,并将 Nginx 恢复指向该端口。 +7. 观察日志和健康状态后,再迁移下一个环境。 + +禁止执行 `pm2 kill`。两个环境完成观察期之前,保留 PM2 定义和服务器源码目录。 + +## 日常部署 + +日常部署只更新一个分支环境: + +1. 解析新的不可变镜像 SHA; +2. 记录当前运行 SHA; +3. 下载源码 Artifact,并利用 Docker 层缓存构建镜像; +4. 使用本地不可变镜像更新对应 Compose 项目; +5. 等待容器健康; +6. 验证 localhost 端口和公网域名; +7. 保留上一 SHA 用于回滚。 + +## 回滚 + +普通 Docker 回滚应将部署元数据改回上一镜像 SHA,然后重新执行 Compose pull/up。关闭故障前必须同时验证 localhost 和公网健康。 + +首次迁移期间,如果 Docker 无法承载该环境: + +1. 停止失败的 Compose 项目; +2. 如 Nginx upstream 已变更,恢复旧配置; +3. 只重启 PM2 中的 `meteorvoice` 或 `meteorvoice-release`; +4. 验证原端口和公网域名。 + +## 验收清单 + +- CI 构建、标记和部署的是同一个 commit。 +- 镜像历史、构建日志和 Artifact 元数据中不存在应用密钥。 +- 预览与生产可独立部署、独立回滚。 +- 容器只绑定 localhost。 +- reload 前 Nginx 配置通过 `nginx -t`。 +- 删除 PM2 定义前完成 PM2 回滚验证。 +- 预览和生产域名均返回 HTTP 200。 +- 完成 Auth、场景加载、Chat、讯飞 ASR/TTS 和 Mobile API 兼容验证。 +- Docker 日志已轮转,旧镜像有明确保留策略。 + +## 相关文档 + +- `docs/deployment-runbook.md`:分支和发布职责。 +- `docs/release-manager.md`:发布自动化。 +- `docs/tts-integration.md`:语音 provider 运行时密钥。 +- `docs/mobile-local-build-runbook.md`:iOS 构建和 API 端点行为。 diff --git a/package-lock.json b/package-lock.json index bf469f3..4bd74db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meteorvoice", - "version": "1.4.1", + "version": "1.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meteorvoice", - "version": "1.4.1", + "version": "1.4.2", "workspaces": [ "packages/*", "apps/*" @@ -57,7 +57,7 @@ }, "apps/mobile": { "name": "@meteorvoice/mobile", - "version": "1.4.1", + "version": "1.4.2", "dependencies": { "@meteorvoice/api-client": "file:../../packages/api-client", "@meteorvoice/session-core": "file:../../packages/session-core", @@ -869,7 +869,7 @@ }, "apps/web": { "name": "@meteorvoice/web", - "version": "1.4.1" + "version": "1.4.2" }, "node_modules/@ai-sdk/deepseek": { "version": "2.0.35", diff --git a/package.json b/package.json index 6968de8..a302dad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "meteorvoice", - "version": "1.4.1", + "version": "1.4.2", "private": true, "workspaces": [ "packages/*", diff --git a/scripts/release-manager.mjs b/scripts/release-manager.mjs index e03b440..8f44e7a 100644 --- a/scripts/release-manager.mjs +++ b/scripts/release-manager.mjs @@ -24,7 +24,7 @@ const config = { releaseDoc: (version) => `docs/releases/v${version}.md`, validation: ['npm test'], releaseUrls: ['https://meteorvoice.jcmeteor.com/', 'https://mv-pre.jcmeteor.com/'], - deployWorkflow: 'Deploy Tencent', + deployWorkflow: 'Deploy Tencent Docker', } const args = process.argv.slice(2) diff --git a/tests/configurable-scenarios.test.ts b/tests/configurable-scenarios.test.ts new file mode 100644 index 0000000..82adc26 --- /dev/null +++ b/tests/configurable-scenarios.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { + mapScenarioRow, + resolveConfiguredScenarios, +} from '@/lib/server/scenarios' +import { scenarios } from '@meteorvoice/shared' +import { resolveRuntimeScenarios } from '../apps/mobile/src/runtimeScenarios' + +const configuredRow = { + key: 'business-trip', + name: 'Business Trip', + name_zh: '商务出差', + description: 'Practice business travel conversations', + description_zh: '练习商务出行对话', + difficulty: 'intermediate', + icon: 'briefcase', +} + +describe('configurable scenarios', () => { + it('maps Supabase rows into the shared scenario contract', () => { + expect(mapScenarioRow(configuredRow)).toEqual({ + key: 'business-trip', + name: 'Business Trip', + description: 'Practice business travel conversations', + labels: { en: 'Business Trip', zh: '商务出差' }, + descriptions: { + en: 'Practice business travel conversations', + zh: '练习商务出行对话', + }, + difficulty: 'intermediate', + icon: '💼', + }) + }) + + it('falls back to built-in scenarios when configuration is unavailable', () => { + expect(resolveConfiguredScenarios(null)).toBe(scenarios) + expect(resolveConfiguredScenarios([])).toBe(scenarios) + expect(resolveRuntimeScenarios([])).toBe(scenarios) + }) + + it('keeps a non-empty remote scenario list for mobile screens', () => { + const configured = resolveConfiguredScenarios([configuredRow]) + + expect(resolveRuntimeScenarios(configured)).toEqual(configured) + }) +})