Browse Source

feat: 引入 env 包以集中管理环境变量,更新相关代码以使用新的 env 结构

acas
npmrun 2 weeks ago
parent
commit
ed4a9cbaa9
  1. 9
      app/composables/useChat.ts
  2. 81
      app/pages/index/index.vue
  3. 11
      bun.lock
  4. 6
      nuxt.config.ts
  5. 1
      package.json
  6. 2
      packages/common/config/index.ts
  7. BIN
      packages/drizzle-pkg/db.sqlite
  8. 4
      packages/drizzle-pkg/drizzle.config.ts
  9. 6
      packages/drizzle-pkg/env.ts
  10. 9
      packages/drizzle-pkg/lib/db.ts
  11. 36
      packages/env/index.ts
  12. 12
      packages/env/package.json
  13. 54
      packages/env/schema.ts
  14. 5
      packages/logger/index.ts
  15. 9
      server/constants/upload.ts
  16. 5
      server/plugins/30.scheduler.ts
  17. 3
      server/service/auth/cookie.ts
  18. 3
      server/service/chat/index.ts
  19. 19
      server/service/oauth/oauth-manager.ts

9
app/composables/useChat.ts

@ -8,6 +8,7 @@
*/
import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory"
import type { ChatMessage } from "~~/server/service/chat"
import { useAuthSession } from "./useAuthSession"
// ══════ Shared SSE state (module-level singleton) ══════
const messages = ref<ChatMessage[]>([])
@ -151,8 +152,14 @@ export function useChat() {
}
if (import.meta.client) {
const { loggedIn } = useAuthSession()
onMounted(() => {
startSSE()
if (loggedIn.value) {
startSSE()
}
watch(loggedIn, (val) => {
if (val) startSSE()
})
loadHistory()
})
}

81
app/pages/index/index.vue

@ -409,6 +409,59 @@ async function loadMore() {
padding-bottom: 40px;
}
/* ── Responsive ── */
@media (max-width: 860px) {
.home-split {
flex-direction: column;
}
.form-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--color-hairline, #e6dfd8);
}
.form-sticky {
position: static;
padding: 28px 20px;
}
.cards-panel {
padding: 28px 20px;
}
.cards-grid {
column-count: 2;
column-gap: 16px;
}
}
@media (max-width: 540px) {
.form-sticky {
padding: 24px 16px;
}
.form-title {
font-size: 22px;
}
.cards-panel {
padding: 20px 12px;
}
.cards-grid {
column-count: 1;
}
.platform-chips {
gap: 6px;
}
.platform-chip {
padding: 5px 12px;
font-size: 12px;
}
}
</style>
<style>
@ -475,30 +528,14 @@ async function loadMore() {
justify-content: flex-end;
}
/* ── Responsive ── */
@media (max-width: 860px) {
.home-split {
flex-direction: column;
}
.form-panel {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--color-hairline, #e6dfd8);
}
.form-sticky {
position: static;
padding: 28px 20px;
}
.cards-panel {
padding: 28px 20px;
@media (max-width: 540px) {
.detail-meta {
flex-wrap: wrap;
gap: 6px;
}
.cards-grid {
column-count: 2;
column-gap: 16px;
.detail-body {
padding: 20px;
}
}
</style>

11
bun.lock

@ -22,6 +22,7 @@
"drizzle-pkg": "workspace:*",
"drizzle-seed": "0.3.1",
"drizzle-zod": "0.8.3",
"env": "workspace:*",
"lodash-es": "4.18.1",
"log4js": "6.9.1",
"logger": "workspace:*",
@ -70,6 +71,14 @@
"packages/drizzle-pkg": {
"name": "drizzle-pkg",
},
"packages/env": {
"name": "env",
"version": "0.1.0",
"dependencies": {
"dotenv": "17.4.1",
"zod": "4.3.6",
},
},
"packages/logger": {
"name": "logger",
},
@ -954,6 +963,8 @@
"entities": ["entities@7.0.1", "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"env": ["env@workspace:packages/env"],
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "https://registry.npmmirror.com/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
"errx": ["errx@0.1.0", "https://registry.npmmirror.com/errx/-/errx-0.1.0.tgz", {}, "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q=="],

6
nuxt.config.ts

@ -54,7 +54,8 @@ export default defineNuxtConfig({
compilerOptions: {
ignoreDeprecations: "6.0",
paths: {
'common': ['./packages/common']
'common': ['./packages/common'],
'env': ['./packages/env']
},
}
}
@ -70,7 +71,8 @@ export default defineNuxtConfig({
'drizzle-pkg': ['./packages/drizzle-pkg'],
'cache': ['./packages/cache'],
'common': ['./packages/common'],
'logger': ['./packages/logger']
'logger': ['./packages/logger'],
'env': ['./packages/env']
},
}
},

1
package.json

@ -36,6 +36,7 @@
"drizzle-pkg": "workspace:*",
"drizzle-seed": "0.3.1",
"drizzle-zod": "0.8.3",
"env": "workspace:*",
"lodash-es": "4.18.1",
"log4js": "6.9.1",
"logger": "workspace:*",

2
packages/common/config/index.ts

@ -30,6 +30,8 @@ export const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/pic/random", methods: ["GET"] },
/** 聊天消息只读 — 公开 */
{ path: "/api/chat/messages", methods: ["GET"] },
/** 想法 — 公开读写(匿名发布) */
{ path: "/api/ideas", methods: ["GET", "POST"] },
];
export const FRONTEND_LOGIN_PATH = "/auth/login"

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

4
packages/drizzle-pkg/drizzle.config.ts

@ -1,11 +1,11 @@
import './env';
import { defineConfig } from 'drizzle-kit';
import { env } from '../env';
export default defineConfig({
out: './migrations',
schema: './lib/schema/*',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL!
url: env.DATABASE_URL,
},
});

6
packages/drizzle-pkg/env.ts

@ -1,4 +1,4 @@
import { config } from 'dotenv';
config({ path: '../../.env' });
// Re-export env from the centralized env package.
// This file is kept for backward-compatible side-effect imports.
export { env } from '../env';

9
packages/drizzle-pkg/lib/db.ts

@ -1,17 +1,14 @@
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
import { resolveSqliteDatabaseUrl } from "./resolve-sqlite-url";
import { env } from "../../env";
if (process.env.NODE_ENV === "production") {
if (env.NODE_ENV === "production") {
// 打包时需要保证migrator被引入
import("drizzle-orm/libsql/migrator");
}
const rawUrl = process.env.DATABASE_URL;
if (!rawUrl) {
throw new Error("DATABASE_URL 未设置");
}
const resolvedUrl = resolveSqliteDatabaseUrl(rawUrl);
const resolvedUrl = resolveSqliteDatabaseUrl(env.DATABASE_URL);
process.env.DATABASE_URL = resolvedUrl;
const client = createClient({ url: resolvedUrl });

36
packages/env/index.ts

@ -0,0 +1,36 @@
import { config } from "dotenv";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { envSchema, type Env } from "./schema";
// ── Load .env from project root ──
// Resolve relative to this file: packages/env/ → ../../.env = project root
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..", "..");
config({ path: path.resolve(projectRoot, ".env") });
// ── Parse & validate ──
function parseEnv(): Env {
const raw: Record<string, string | undefined> = {};
// Collect only the keys we care about from process.env
for (const key of Object.keys(envSchema.shape)) {
raw[key] = process.env[key];
}
const result = envSchema.safeParse(raw);
if (!result.success) {
const errors = result.error.issues
.map((issue) => ` - ${issue.path.join(".")}: ${issue.message}`)
.join("\n");
throw new Error(`Environment variable validation failed:\n${errors}`);
}
return result.data;
}
export const env: Readonly<Env> = parseEnv();
// Re-export the type for consumers
export type { Env } from "./schema";

12
packages/env/package.json

@ -0,0 +1,12 @@
{
"name": "env",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./index.ts"
},
"dependencies": {
"dotenv": "17.4.1",
"zod": "4.3.6"
}
}

54
packages/env/schema.ts

@ -0,0 +1,54 @@
import { z } from "zod";
/**
* Zod schema for all environment variables used across the project.
*
* Every `process.env.X` access should be replaced by importing `env` from
* this package and using the typed, validated value.
*
* To add a new env var:
* 1. Add it to this schema with the correct type, default, and validation.
* 2. The typed `env` object is auto-generated from the schema's output.
*/
export const envSchema = z.object({
// ── Database ──
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
// ── Node / Runtime ──
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
// ── Logging (logger package) ──
LOG_MAX_SIZE: z.coerce.number().int().positive().default(10 * 1024 * 1024), // 10 MB
LOG_MAX_BACKUPS: z.coerce.number().int().nonnegative().default(5),
// ── Static file serving ──
STATIC_DIR: z.string().default("static"),
UPLOAD_SUBDIR: z.string().default("upload"),
// ── Scheduler ──
SCHEDULER_MAX_CONCURRENCY: z.coerce.number().int().positive().default(5),
SCHEDULER_LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
// ── App ──
APP_URL: z.string().min(1, "APP_URL is required"),
NITRO_PORT: z.coerce.number().int().positive(),
// ── OAuth: GitHub ──
GITHUB_CLIENT_ID: z.string().default(""),
GITHUB_CLIENT_SECRET: z.string().default(""),
// ── OAuth: Gitea ──
GITEA_CLIENT_ID: z.string().default(""),
GITEA_CLIENT_SECRET: z.string().default(""),
GITEA_URL: z.string().default(""),
// ── Bootstrap (seed) ──
BOOTSTRAP_ADMIN_USERNAME: z.string().optional(),
BOOTSTRAP_ADMIN_PASSWORD: z.string().optional(),
});
/** Inferred type of the parsed env object. */
export type Env = z.infer<typeof envSchema>;

5
packages/logger/index.ts

@ -1,11 +1,12 @@
import path from "node:path";
import log4js from "log4js";
import fs from "node:fs";
import { env } from "env";
const logDir = path.resolve(process.cwd(), "logs");
const pathLog = path.resolve(logDir, "running.log");
const maxLogSize = Number(process.env.LOG_MAX_SIZE ?? 10 * 1024 * 1024); // 10MB
const maxBackups = Number(process.env.LOG_MAX_BACKUPS ?? 5);
const maxLogSize = env.LOG_MAX_SIZE;
const maxBackups = env.LOG_MAX_BACKUPS;
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });

9
server/constants/upload.ts

@ -1,4 +1,5 @@
import path from "node:path";
import { env } from "env";
function trimSlashes(input: string): string {
return input.trim().replace(/^\/+|\/+$/g, "");
@ -40,14 +41,10 @@ function ensureSafeSubdir(input: string, fallback: string, envName: string): str
export const STATIC_PUBLIC_PREFIX = "/static";
/** 静态资源根目录(相对项目根或绝对路径),默认 `static` */
export const STATIC_DIR = ensureConfigurableDir(process.env.STATIC_DIR ?? "static", "static", "STATIC_DIR");
export const STATIC_DIR = ensureConfigurableDir(env.STATIC_DIR, "static", "STATIC_DIR");
/** 媒体上传子目录(相对 STATIC_DIR),默认 `upload` */
export const UPLOAD_SUBDIR = ensureSafeSubdir(
process.env.UPLOAD_SUBDIR ?? "upload",
"upload",
"UPLOAD_SUBDIR",
);
export const UPLOAD_SUBDIR = ensureSafeSubdir(env.UPLOAD_SUBDIR, "upload", "UPLOAD_SUBDIR");
/** 媒体上传目录(与 STATIC_DIR 同为相对或绝对),默认 `static/upload` */
export const RELATIVE_ASSETS_DIR = path.join(STATIC_DIR, UPLOAD_SUBDIR);

5
server/plugins/30.scheduler.ts

@ -1,9 +1,10 @@
import { start, stop } from "../scheduler/engine";
import { registerTask } from "../scheduler/registry";
import cleanupLogs from "../scheduler/tasks/cleanup-logs";
import { env } from "env";
const MAX_CONCURRENCY = Number(process.env.SCHEDULER_MAX_CONCURRENCY) || 5;
const LOG_RETENTION_DAYS = Number(process.env.SCHEDULER_LOG_RETENTION_DAYS) || 30;
const MAX_CONCURRENCY = env.SCHEDULER_MAX_CONCURRENCY;
const LOG_RETENTION_DAYS = env.SCHEDULER_LOG_RETENTION_DAYS;
if (import.meta.dev) {
console.log("plugin: 03.scheduler");

3
server/service/auth/cookie.ts

@ -5,6 +5,7 @@ import {
SESSION_COOKIE_PATH,
SESSION_MAX_AGE_SECONDS,
} from "#server/constants/auth";
import { env } from "env";
export function getSessionId(event: H3Event) {
return getCookie(event, SESSION_COOKIE_NAME);
@ -20,7 +21,7 @@ export function setSessionCookie(event: H3Event, sessionId: string) {
setCookie(event, SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
secure: env.NODE_ENV === "production",
path: SESSION_COOKIE_PATH,
maxAge: SESSION_MAX_AGE_SECONDS,
});

3
server/service/chat/index.ts

@ -9,6 +9,7 @@ import { sendMail } from "#server/service/email"
import { dbGlobal } from "drizzle-pkg/lib/db"
import { chatMessages } from "drizzle-pkg/lib/schema/content"
import { desc, sql } from "drizzle-orm"
import { env } from "env"
// ── Types ──
export interface ChatMessage {
@ -188,7 +189,7 @@ export async function sendMessage(
sendMail({
to: email,
subject: `[聊天室] @${mention} — 来自 ${displayNickname} 的消息`,
text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${process.env.APP_URL || '(未配置)'}`,
text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${env.APP_URL || '(未配置)'}`,
}).catch(err => {
console.error('Failed to send @mention email:', err)
})

19
server/service/oauth/oauth-manager.ts

@ -7,6 +7,7 @@ import { OAuthError, OAuthErrorCodes } from './oauth-error';
import { registerUser, createSession } from '#server/service/auth';
import type { MinimalUser } from '#server/service/auth';
import { FRONTEND_LOGIN_PATH } from "common/config"
import { env } from "env";
const logger = log4js.getLogger("OAUTH");
@ -57,13 +58,13 @@ const providers: Record<string, OAuthProviderConfig> = {
github: {
name: 'github',
icon: 'github',
clientId: process.env.GITHUB_CLIENT_ID || '',
clientSecret: process.env.GITHUB_CLIENT_SECRET || '',
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
authorizeUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user',
scopes: ['read:user', 'user:email'],
redirectUri: `${process.env.APP_URL}/api/auth/oauth/github/callback`,
redirectUri: `${env.APP_URL}/api/auth/oauth/github/callback`,
userInfoMapping: {
providerUserId: 'id',
username: 'login',
@ -74,15 +75,15 @@ const providers: Record<string, OAuthProviderConfig> = {
gitea: {
name: 'gitea',
icon: 'gitea', // 或者使用自定义图标
clientId: process.env.GITEA_CLIENT_ID || '',
clientSecret: process.env.GITEA_CLIENT_SECRET || '',
authorizeUrl: `${process.env.GITEA_URL}/login/oauth/authorize`,
tokenUrl: `${process.env.GITEA_URL}/login/oauth/access_token`,
userInfoUrl: `${process.env.GITEA_URL}/api/v1/user`,
clientId: env.GITEA_CLIENT_ID,
clientSecret: env.GITEA_CLIENT_SECRET,
authorizeUrl: `${env.GITEA_URL}/login/oauth/authorize`,
tokenUrl: `${env.GITEA_URL}/login/oauth/access_token`,
userInfoUrl: `${env.GITEA_URL}/api/v1/user`,
scopes: ['read:user', 'user:email'],
grant_type: "authorization_code",
responseType: "code",
redirectUri: `${process.env.APP_URL}/api/auth/oauth/gitea/callback`,
redirectUri: `${env.APP_URL}/api/auth/oauth/gitea/callback`,
userInfoMapping: {
providerUserId: 'id',
username: 'login',

Loading…
Cancel
Save