feat: complete V1 phases 0-7

This commit is contained in:
2026-08-12 14:35:12 +08:00
commit c3dd9be674
311 changed files with 38750 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import { Injectable } from '@nestjs/common';
export interface DatabaseConnectionOptions {
host: string;
port: number;
user: string;
password: string;
database: string;
}
function requiredEnvironmentValue(name: string): string {
const value = process.env[name]?.trim();
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
function positiveInteger(name: string, fallback: number): number {
const raw = process.env[name]?.trim();
if (!raw) return fallback;
const value = Number(raw);
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer`);
}
return value;
}
function parseBoolean(name: string, fallback: boolean): boolean {
const raw = process.env[name]?.trim().toLowerCase();
if (!raw) return fallback;
if (raw === 'true') return true;
if (raw === 'false') return false;
throw new Error(`${name} must be true or false`);
}
@Injectable()
export class AppConfigService {
readonly appEnvironment = process.env.APP_ENV?.trim() || 'development';
readonly apiPort = positiveInteger('API_PORT', 3000);
readonly databaseUrl = requiredEnvironmentValue('DATABASE_URL');
readonly sessionTokenPepper = requiredEnvironmentValue('SESSION_TOKEN_PEPPER');
readonly auditIpHashKey = requiredEnvironmentValue('AUDIT_IP_HASH_KEY');
readonly adminWebOrigin = requiredEnvironmentValue('ADMIN_WEB_ORIGIN');
readonly adminCookieSecure = parseBoolean('ADMIN_COOKIE_SECURE', true);
readonly adminSessionTtlSeconds = positiveInteger('AUTH_SESSION_TTL_SECONDS', 28_800);
readonly wechatSessionTtlSeconds = positiveInteger('WECHAT_SESSION_TTL_SECONDS', 7_200);
readonly wechatAppId = process.env.WECHAT_APP_ID?.trim() || null;
readonly wechatAppSecret = process.env.WECHAT_APP_SECRET?.trim() || null;
readonly wechatNotificationTemplates = {
APPLICATION_SUBMITTED: process.env.WECHAT_TEMPLATE_APPLICATION_SUBMITTED?.trim() || null,
APPLICATION_ACCEPTED: process.env.WECHAT_TEMPLATE_APPLICATION_ACCEPTED?.trim() || null,
SERVICE_ARRANGED: process.env.WECHAT_TEMPLATE_SERVICE_ARRANGED?.trim() || null,
SERVICE_COMPLETED: process.env.WECHAT_TEMPLATE_SERVICE_COMPLETED?.trim() || null,
FOLLOW_UP_PENDING: process.env.WECHAT_TEMPLATE_FOLLOW_UP_PENDING?.trim() || null,
ACTIVITY_REGISTRATION_RESULT:
process.env.WECHAT_TEMPLATE_ACTIVITY_REGISTRATION_RESULT?.trim() || null,
} as const;
readonly notificationWorkerEnabled = parseBoolean('NOTIFICATION_WORKER_ENABLED', false);
readonly objectStorageEndpoint = process.env.OBJECT_STORAGE_ENDPOINT?.trim() || null;
readonly objectStorageRegion = process.env.OBJECT_STORAGE_REGION?.trim() || 'auto';
readonly objectStorageBucket = process.env.OBJECT_STORAGE_BUCKET?.trim() || null;
readonly objectStorageAccessKey = process.env.OBJECT_STORAGE_ACCESS_KEY?.trim() || null;
readonly objectStorageSecretKey = process.env.OBJECT_STORAGE_SECRET_KEY?.trim() || null;
readonly objectStorageForcePathStyle = parseBoolean('OBJECT_STORAGE_FORCE_PATH_STYLE', true);
readonly fieldEncryptionKey = this.parseEncryptionKey();
readonly database = this.parseDatabaseUrl();
get isProduction(): boolean {
return this.appEnvironment === 'production';
}
get objectStorageConfigured(): boolean {
return Boolean(
this.objectStorageEndpoint &&
this.objectStorageBucket &&
this.objectStorageAccessKey &&
this.objectStorageSecretKey,
);
}
get wechatNotificationConfigured(): boolean {
return Boolean(
this.wechatAppId &&
this.wechatAppSecret &&
Object.values(this.wechatNotificationTemplates).every(Boolean),
);
}
private parseEncryptionKey(): Buffer {
const encoded = requiredEnvironmentValue('FIELD_ENCRYPTION_KEY');
const key = Buffer.from(encoded, 'base64');
if (key.length !== 32) {
throw new Error('FIELD_ENCRYPTION_KEY must be a base64-encoded 32-byte key');
}
return key;
}
private parseDatabaseUrl(): DatabaseConnectionOptions {
const url = new URL(this.databaseUrl);
if (url.protocol !== 'mysql:') {
throw new Error('DATABASE_URL must use the mysql protocol');
}
const database = url.pathname.replace(/^\//, '');
if (!url.hostname || !url.username || !database) {
throw new Error('DATABASE_URL must include host, user, and database');
}
return {
host: url.hostname,
port: url.port ? Number(url.port) : 3306,
user: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
database,
};
}
}