refactor(GOV-009): 彻底切换为仅源码部署
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
FROM node:22.22.0-bookworm-slim AS builder
|
||||
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY web/package.json web/package.json
|
||||
COPY server/package.json server/package.json
|
||||
RUN pnpm install --frozen-lockfile --filter @a-share-analysis/server...
|
||||
|
||||
COPY server server
|
||||
RUN pnpm --filter @a-share-analysis/server build
|
||||
RUN pnpm --filter @a-share-analysis/server deploy --legacy --prod /runtime
|
||||
|
||||
FROM node:22.22.0-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production
|
||||
ENV APP_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=node:node /runtime/package.json ./package.json
|
||||
COPY --from=builder --chown=node:node /runtime/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /workspace/server/dist ./dist
|
||||
USER node
|
||||
CMD ["node", "dist/apps/api/main.js"]
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"openapi:generate": "tsx tools/export-openapi.ts",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test": "vitest run && node --test tools/run-test-api.test.mjs",
|
||||
"test:watch": "vitest",
|
||||
"build": "node tools/clean-build-output.mjs && nest build api && nest build scheduler && nest build worker && node tools/verify-build-output.mjs"
|
||||
},
|
||||
|
||||
@@ -1,103 +1,89 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createConnection } from 'node:net';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const serverRoot = path.resolve(import.meta.dirname, '..');
|
||||
const launcher = path.join(serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
||||
const redisContainerName = `a-share-analysis-e2e-redis-${process.pid}`;
|
||||
let ownsRedisContainer = false;
|
||||
let redisPort = '6379';
|
||||
|
||||
const localRedisAvailable = await new Promise((resolve) => {
|
||||
const socket = createConnection({ host: '127.0.0.1', port: 6379 });
|
||||
const finish = (available) => {
|
||||
socket.destroy();
|
||||
resolve(available);
|
||||
};
|
||||
socket.setTimeout(500, () => finish(false));
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
});
|
||||
/**
|
||||
* E2E 只检查开发者已维护的本机 Redis,不安装、启动或停止任何外部服务。
|
||||
* 失败信息固定指向项目要求的公开本地端点,避免泄漏可注入的测试地址。
|
||||
*/
|
||||
export function checkLocalRedis({ host = '127.0.0.1', port = 6379, timeoutMs = 500 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = createConnection({ host, port });
|
||||
let settled = false;
|
||||
|
||||
if (!localRedisAvailable) {
|
||||
execFileSync(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'--detach',
|
||||
'--name',
|
||||
redisContainerName,
|
||||
'--publish',
|
||||
'127.0.0.1::6379',
|
||||
'redis:7.4.8-alpine3.21',
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'inherit'] },
|
||||
);
|
||||
ownsRedisContainer = true;
|
||||
redisPort = execFileSync('docker', ['port', redisContainerName, '6379/tcp'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
.trim()
|
||||
.split(':')
|
||||
.at(-1);
|
||||
const finish = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
socket.destroy();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
const unavailable = () =>
|
||||
finish(new Error('本机 Redis 127.0.0.1:6379 不可达,请确认外部 Redis 服务启动后重试'));
|
||||
|
||||
socket.setTimeout(timeoutMs, unavailable);
|
||||
socket.once('connect', () => finish());
|
||||
socket.once('error', unavailable);
|
||||
});
|
||||
}
|
||||
|
||||
// 浏览器集成测试只启用 API 与禁用态 MySQL;Redis 故障会形成可验证的安全 503 响应,
|
||||
// 所有值均为公开测试夹具,不读取开发者的 *.env.local 或任何生产配置。
|
||||
const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], {
|
||||
cwd: serverRoot,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
APP_ENV: 'production',
|
||||
LOG_LEVEL: 'error',
|
||||
API_HOST: '127.0.0.1',
|
||||
API_PORT: '3000',
|
||||
REDIS_HOST: '127.0.0.1',
|
||||
REDIS_PORT: redisPort,
|
||||
REDIS_USERNAME: '',
|
||||
REDIS_PASSWORD: '',
|
||||
REDIS_DATABASE: '0',
|
||||
REDIS_TLS: 'false',
|
||||
MYSQL_ENABLED: 'false',
|
||||
MYSQL_HOST: '127.0.0.1',
|
||||
MYSQL_PORT: '3306',
|
||||
MYSQL_DATABASE: 'a_share_analysis_dev',
|
||||
MYSQL_USERNAME: '',
|
||||
MYSQL_PASSWORD: '',
|
||||
MYSQL_CHARSET: 'utf8mb4',
|
||||
MYSQL_COLLATION: 'utf8mb4_general_ci',
|
||||
MYSQL_TIMEZONE: '+08:00',
|
||||
AI_ENABLED: 'false',
|
||||
AI_BASE_URL: '',
|
||||
AI_API_KEY: '',
|
||||
AI_MODEL: '',
|
||||
AI_TIMEOUT_MS: '30000',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
export async function runTestApi() {
|
||||
await checkLocalRedis();
|
||||
|
||||
const forwardSignal = (signal) => {
|
||||
if (ownsRedisContainer) {
|
||||
try {
|
||||
execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Playwright 可能已因启动失败清理容器,重复清理保持幂等。
|
||||
}
|
||||
}
|
||||
if (child.exitCode === null) child.kill(signal);
|
||||
};
|
||||
process.once('SIGINT', () => forwardSignal('SIGINT'));
|
||||
process.once('SIGTERM', () => forwardSignal('SIGTERM'));
|
||||
child.once('exit', (code, signal) => {
|
||||
if (ownsRedisContainer) {
|
||||
try {
|
||||
execFileSync('docker', ['stop', redisContainerName], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// 容器已由信号处理清理时无需再次报告错误。
|
||||
}
|
||||
}
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
else process.exit(code ?? 1);
|
||||
});
|
||||
// 浏览器集成测试只启用 API 与 Redis,其他依赖使用公开禁用态夹具;这里不会读取
|
||||
// 开发者的 *.env.local 或任何生产配置,也不会把敏感值传给子进程。
|
||||
const child = spawn(process.execPath, [launcher, 'apps/api/src/main.ts'], {
|
||||
cwd: serverRoot,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
APP_ENV: 'production',
|
||||
LOG_LEVEL: 'error',
|
||||
API_HOST: '127.0.0.1',
|
||||
API_PORT: '3000',
|
||||
REDIS_HOST: '127.0.0.1',
|
||||
REDIS_PORT: '6379',
|
||||
REDIS_USERNAME: '',
|
||||
REDIS_PASSWORD: '',
|
||||
REDIS_DATABASE: '0',
|
||||
REDIS_TLS: 'false',
|
||||
MYSQL_ENABLED: 'false',
|
||||
MYSQL_HOST: '127.0.0.1',
|
||||
MYSQL_PORT: '3306',
|
||||
MYSQL_DATABASE: 'a_share_analysis_dev',
|
||||
MYSQL_USERNAME: '',
|
||||
MYSQL_PASSWORD: '',
|
||||
MYSQL_CHARSET: 'utf8mb4',
|
||||
MYSQL_COLLATION: 'utf8mb4_general_ci',
|
||||
MYSQL_TIMEZONE: '+08:00',
|
||||
AI_ENABLED: 'false',
|
||||
AI_BASE_URL: '',
|
||||
AI_API_KEY: '',
|
||||
AI_MODEL: '',
|
||||
AI_TIMEOUT_MS: '30000',
|
||||
},
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const forwardSignal = (signal) => {
|
||||
if (child.exitCode === null) child.kill(signal);
|
||||
};
|
||||
process.once('SIGINT', () => forwardSignal('SIGINT'));
|
||||
process.once('SIGTERM', () => forwardSignal('SIGTERM'));
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
else process.exit(code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))
|
||||
) {
|
||||
await runTestApi();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import net from 'node:net';
|
||||
import test from 'node:test';
|
||||
|
||||
import { checkLocalRedis } from './run-test-api.mjs';
|
||||
|
||||
function listenOnLoopback() {
|
||||
const server = net.createServer();
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function closeServer(server) {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
test('Redis 不可达时给出外部服务提示并拒绝启动', async () => {
|
||||
const server = await listenOnLoopback();
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== 'string');
|
||||
await closeServer(server);
|
||||
|
||||
await assert.rejects(
|
||||
checkLocalRedis({ host: '127.0.0.1', port: address.port, timeoutMs: 100 }),
|
||||
/本机 Redis.*127\.0\.0\.1:6379.*启动后重试/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Redis TCP 可达时预检通过', async (context) => {
|
||||
const server = await listenOnLoopback();
|
||||
context.after(() => closeServer(server));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== 'string');
|
||||
|
||||
await assert.doesNotReject(() =>
|
||||
checkLocalRedis({ host: '127.0.0.1', port: address.port, timeoutMs: 100 }),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user