36 changed files with 779 additions and 346 deletions
@ -1 +1,4 @@ |
|||
DATABASE_URL=postgresql://postgres:xxxxxx@localhost:6666/postgres |
|||
DATABASE_URL=file:./db.sqlite |
|||
STATIC_DIR=static |
|||
UPLOAD_SUBDIR=upload |
|||
NITRO_PORT=3399 |
|||
@ -0,0 +1,117 @@ |
|||
/** |
|||
* 部署后首次启动前执行:若库中尚无 admin,则用环境变量创建首个管理员。 |
|||
* 逻辑对齐 `packages/drizzle-pkg/seed.ts`,使用 SQLite(与 `migrate-sqlite.js` 相同)。 |
|||
* |
|||
* 环境变量(与 seed.ts 一致): |
|||
* - DATABASE_URL:SQLite 路径,可为 `file:/path/to/db.sqlite` 或裸路径 |
|||
* - BOOTSTRAP_ADMIN_USERNAME / BOOTSTRAP_ADMIN_PASSWORD:可选;未设置或校验失败则跳过 |
|||
*/ |
|||
import Database from 'better-sqlite3' |
|||
import { hash } from 'bcryptjs' |
|||
import { mkdirSync } from 'node:fs' |
|||
import path from 'node:path' |
|||
import { fileURLToPath } from 'node:url' |
|||
|
|||
/** 与 `server/service/auth/index.ts` 一致 */ |
|||
const USERNAME_REGEX = /^[a-zA-Z0-9_]{3,20}$/ |
|||
const MIN_PASSWORD_LENGTH = 6 |
|||
|
|||
/** 与 seed.ts 一致:仅当小写用户名本身符合 slug 规则时写入 public_slug */ |
|||
const PUBLIC_SLUG_REGEX = /^[a-z0-9]{3,20}$/ |
|||
|
|||
function derivePublicSlug(username) { |
|||
const lower = username.toLowerCase() |
|||
return PUBLIC_SLUG_REGEX.test(lower) ? lower : null |
|||
} |
|||
|
|||
/** |
|||
* 与 `migrate-sqlite.js` 一致:相对路径相对 `process.cwd()`(例如 `.output/run.sh` 下与迁移写入同一文件)。 |
|||
* Nitro 内 `resolveSqliteDatabaseUrl` 在无法锚定 drizzle-pkg 时也会回退到 cwd,避免「迁移/seed 成功、服务 CANTOPEN」。 |
|||
*/ |
|||
function resolveSqliteFilePath(dbUrl) { |
|||
const stripped = dbUrl.startsWith('file:') ? dbUrl.slice('file:'.length) : dbUrl |
|||
if (!stripped) { |
|||
throw new Error('DATABASE_URL 未设置,且未提供有效的 SQLite 文件路径') |
|||
} |
|||
if (path.isAbsolute(stripped)) { |
|||
return stripped |
|||
} |
|||
return path.resolve(process.cwd(), stripped) |
|||
} |
|||
|
|||
function openSqlite() { |
|||
const dbUrl = process.env.DATABASE_URL || '' |
|||
const sqlitePath = resolveSqliteFilePath(dbUrl) |
|||
mkdirSync(path.dirname(sqlitePath), { recursive: true }) |
|||
return new Database(sqlitePath) |
|||
} |
|||
|
|||
async function main() { |
|||
const db = openSqlite() |
|||
try { |
|||
const existingAdmin = db |
|||
.prepare(`SELECT id FROM users WHERE role = ? LIMIT 1`) |
|||
.get('admin') |
|||
if (existingAdmin) { |
|||
console.log('Bootstrap skipped: admin exists') |
|||
return |
|||
} |
|||
|
|||
const username = process.env.BOOTSTRAP_ADMIN_USERNAME |
|||
const password = process.env.BOOTSTRAP_ADMIN_PASSWORD |
|||
|
|||
if (!username || !password) { |
|||
console.warn( |
|||
'Bootstrap skipped: set BOOTSTRAP_ADMIN_USERNAME and BOOTSTRAP_ADMIN_PASSWORD', |
|||
) |
|||
return |
|||
} |
|||
|
|||
if (!USERNAME_REGEX.test(username) || password.length < MIN_PASSWORD_LENGTH) { |
|||
console.warn( |
|||
'Bootstrap skipped: invalid BOOTSTRAP_ADMIN_USERNAME or BOOTSTRAP_ADMIN_PASSWORD', |
|||
) |
|||
return |
|||
} |
|||
|
|||
const passwordHash = await hash(password, 10) |
|||
const { maxId } = db.prepare(`SELECT COALESCE(MAX(id), 0) AS maxId FROM users`).get() |
|||
const userId = (maxId ?? 0) + 1 |
|||
const publicSlug = derivePublicSlug(username) |
|||
|
|||
try { |
|||
db.prepare( |
|||
` |
|||
INSERT INTO users (id, username, password, role, status, public_slug) |
|||
VALUES (@id, @username, @password, @role, @status, @public_slug) |
|||
`,
|
|||
).run({ |
|||
id: userId, |
|||
username, |
|||
password: passwordHash, |
|||
role: 'admin', |
|||
status: 'active', |
|||
public_slug: publicSlug, |
|||
}) |
|||
console.log('Bootstrap complete: admin user created') |
|||
} catch (err) { |
|||
console.warn( |
|||
'Bootstrap skipped: could not insert admin (unique conflict or DB error)', |
|||
err, |
|||
) |
|||
} |
|||
} finally { |
|||
db.close() |
|||
} |
|||
} |
|||
|
|||
const isMain = |
|||
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) |
|||
|
|||
if (isMain) { |
|||
// 成功时自然退出(退出码 0);仅未捕获错误时 process.exit(1)。
|
|||
main().catch((e) => { |
|||
console.error(e) |
|||
process.exit(1) |
|||
}) |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
|
|||
import { drizzle } from "drizzle-orm/node-postgres"; |
|||
|
|||
if (process.env.NODE_ENV === 'production') { |
|||
// 打包时需要保证migrator被引入
|
|||
import('drizzle-orm/node-postgres/migrator') |
|||
} |
|||
|
|||
const _db = drizzle(process.env.DATABASE_URL!); |
|||
|
|||
export { _db as dbGlobal } |
|||
@ -1,16 +0,0 @@ |
|||
import { sql } from "drizzle-orm"; |
|||
import { integer, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; |
|||
|
|||
export const users = pgTable("users", { |
|||
id: integer().primaryKey(), |
|||
username: varchar().notNull().unique(), |
|||
email: varchar(), |
|||
nickname: varchar(), |
|||
password: varchar().notNull(), |
|||
avatar: varchar(), |
|||
createdAt: timestamp('created_at').defaultNow().notNull(), |
|||
updatedAt: timestamp('updated_at') |
|||
.defaultNow() |
|||
.$onUpdate(() => sql`CURRENT_TIMESTAMP`) |
|||
.notNull(), |
|||
}); |
|||
@ -1,10 +0,0 @@ |
|||
import { drizzle } from 'drizzle-orm/libsql'; |
|||
|
|||
if (process.env.NODE_ENV === 'production') { |
|||
// 打包时需要保证migrator被引入
|
|||
import('drizzle-orm/better-sqlite3/migrator') |
|||
} |
|||
|
|||
const _db = drizzle(process.env.DATABASE_URL!); |
|||
|
|||
export { _db as dbGlobal } |
|||
@ -1 +1,23 @@ |
|||
export { dbGlobal } from '../database/pg/db' |
|||
import { drizzle } from "drizzle-orm/better-sqlite3"; |
|||
import { resolveSqliteDatabaseUrl } from "./resolve-sqlite-url"; |
|||
|
|||
if (process.env.NODE_ENV === "production") { |
|||
// 打包时需要保证migrator被引入
|
|||
import("drizzle-orm/better-sqlite3/migrator"); |
|||
} |
|||
|
|||
const rawUrl = process.env.DATABASE_URL; |
|||
if (!rawUrl) { |
|||
throw new Error("DATABASE_URL 未设置"); |
|||
} |
|||
const resolvedUrl = resolveSqliteDatabaseUrl(rawUrl); |
|||
process.env.DATABASE_URL = resolvedUrl; |
|||
|
|||
// better-sqlite3 需要裸文件路径;`file:` 前缀仍保留在 DATABASE_URL 供 drizzle-kit 等使用
|
|||
const sqlitePath = resolvedUrl.startsWith("file:") |
|||
? resolvedUrl.slice("file:".length) |
|||
: resolvedUrl; |
|||
|
|||
const _db = drizzle(sqlitePath); |
|||
|
|||
export { _db as dbGlobal }; |
|||
|
|||
@ -0,0 +1,33 @@ |
|||
import { existsSync, readFileSync } from "node:fs"; |
|||
import path from "node:path"; |
|||
import { fileURLToPath } from "node:url"; |
|||
|
|||
function isDrizzlePkgRoot(dir: string): boolean { |
|||
const pkg = path.join(dir, "package.json"); |
|||
if (!existsSync(pkg)) { |
|||
return false; |
|||
} |
|||
try { |
|||
const { name } = JSON.parse(readFileSync(pkg, "utf8")) as { name?: string }; |
|||
return name === "drizzle-pkg"; |
|||
} catch { |
|||
return false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* `drizzle-pkg` 包根目录(与 `package.json`、`db.sqlite` 同级)。 |
|||
* - 开发:用 `import.meta` 锚定,避免 cwd 变化把 `file:db.sqlite` 指到错误文件(只读 / DBMOVED)。 |
|||
* - 生产:打包后 chunk 路径不可靠,回退到 `cwd/packages/drizzle-pkg`;再不行则回退 `process.cwd()`(与 `.output` 下 migrate/seed 的相对 `DATABASE_URL` 一致)。 |
|||
*/ |
|||
export function getDrizzlePkgRoot(): string { |
|||
const fromMeta = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); |
|||
if (isDrizzlePkgRoot(fromMeta)) { |
|||
return fromMeta; |
|||
} |
|||
const fromCwd = path.resolve(process.cwd(), "packages/drizzle-pkg"); |
|||
if (isDrizzlePkgRoot(fromCwd)) { |
|||
return fromCwd; |
|||
} |
|||
return process.cwd(); |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
import path from "node:path"; |
|||
import { getDrizzlePkgRoot } from "./paths"; |
|||
|
|||
/** 将 `file:` 相对路径解析为绝对路径(相对 drizzle-pkg 根目录) */ |
|||
export function resolveSqliteDatabaseUrl(url: string): string { |
|||
if (!url.startsWith("file:")) { |
|||
return url; |
|||
} |
|||
let filePath = url.slice("file:".length); |
|||
if (path.isAbsolute(filePath)) { |
|||
return `file:${filePath}`; |
|||
} |
|||
const root = getDrizzlePkgRoot(); |
|||
return `file:${path.resolve(root, filePath)}`; |
|||
} |
|||
@ -1 +1,25 @@ |
|||
export { users } from '../../database/pg/schema/auth' |
|||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; |
|||
|
|||
export const users = sqliteTable("users", { |
|||
id: integer().primaryKey(), |
|||
username: text().notNull().unique(), |
|||
email: text(), |
|||
nickname: text(), |
|||
password: text().notNull(), |
|||
avatar: text(), |
|||
role: text().notNull().default("user"), |
|||
status: text().notNull().default("active"), |
|||
publicSlug: text("public_slug").unique(), |
|||
bioMarkdown: text("bio_markdown"), |
|||
bioVisibility: text("bio_visibility").notNull().default("private"), |
|||
socialLinksJson: text("social_links_json").notNull().default("[]"), |
|||
avatarVisibility: text("avatar_visibility").notNull().default("private"), |
|||
discoverVisible: integer("discover_visible", { mode: "boolean" }).notNull().default(true), |
|||
discoverLocation: text("discover_location"), |
|||
discoverShowLocation: integer("discover_show_location", { mode: "boolean" }).notNull().default(false), |
|||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), |
|||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }) |
|||
.defaultNow() |
|||
.$onUpdate(() => new Date()) |
|||
.notNull(), |
|||
}); |
|||
|
|||
@ -0,0 +1,50 @@ |
|||
#!/usr/bin/env sh |
|||
|
|||
# 配置区(只改这里) |
|||
GIT_REPO_URL="ssh://root@git.xieyaxin.top:8892/topuser/nuxt4-demo.git" |
|||
PROD_BRANCH="deploy" # 你要存放产物的分支名(会自动创建) |
|||
BUILD_FOLDER=".output" # 打包产物目录 |
|||
ARCHIVE_NAME="build-output.tar.gz" |
|||
ARCHIVE_PATH=".tmp_${ARCHIVE_NAME}" |
|||
COMMIT_MSG="deploy: build at $(date +'%Y-%m-%d %H:%M:%S')" |
|||
|
|||
# 1. 先打包 |
|||
echo "📦 构建项目..." |
|||
bun run build |
|||
|
|||
# 2. 克隆远程产物分支到临时目录 |
|||
echo "⬇️ 拉取产物分支..." |
|||
git clone --single-branch --branch $PROD_BRANCH $GIT_REPO_URL .tmp_dist || { |
|||
echo "🆕 分支不存在,创建新分支..." |
|||
mkdir .tmp_dist |
|||
cd .tmp_dist |
|||
git init |
|||
git checkout -b $PROD_BRANCH |
|||
git remote add origin $GIT_REPO_URL |
|||
cd .. |
|||
} |
|||
|
|||
# 3. 压缩构建产物 |
|||
echo "🗜️ 压缩构建目录..." |
|||
tar -czf "$ARCHIVE_PATH" -C "$BUILD_FOLDER" . |
|||
|
|||
# 4. 删除旧产物,复制新产物 |
|||
echo "♻️ 更新产物文件..." |
|||
rm -rf .tmp_dist/* |
|||
cp -r $BUILD_FOLDER/.drone.yml .tmp_dist/.drone.yml |
|||
cp "$ARCHIVE_PATH" ".tmp_dist/$ARCHIVE_NAME" |
|||
|
|||
# 5. 提交并推送 |
|||
cd .tmp_dist |
|||
git add -A |
|||
git commit -m "$COMMIT_MSG" |
|||
|
|||
echo "🚀 推送到远程分支 $PROD_BRANCH..." |
|||
git push origin $PROD_BRANCH |
|||
|
|||
# 6. 清理临时文件 |
|||
cd .. |
|||
rm -rf .tmp_dist |
|||
rm -f "$ARCHIVE_PATH" |
|||
|
|||
echo "✅ 发布完成!" |
|||
@ -0,0 +1,50 @@ |
|||
#!/usr/bin/env sh |
|||
|
|||
# 配置区(只改这里) |
|||
GIT_REPO_URL="git@gitee.com:xieyaxin/nuxt4-demo.git" |
|||
PROD_BRANCH="deploy" # 你要存放产物的分支名(会自动创建) |
|||
BUILD_FOLDER=".output" # 打包产物目录 |
|||
ARCHIVE_NAME="build-output.tar.gz" |
|||
ARCHIVE_PATH=".tmp_${ARCHIVE_NAME}" |
|||
COMMIT_MSG="deploy: build at $(date +'%Y-%m-%d %H:%M:%S')" |
|||
|
|||
# 1. 先打包 |
|||
echo "📦 构建项目..." |
|||
bun run build |
|||
|
|||
# 2. 克隆远程产物分支到临时目录 |
|||
echo "⬇️ 拉取产物分支..." |
|||
git clone --single-branch --branch $PROD_BRANCH $GIT_REPO_URL .tmp_dist || { |
|||
echo "🆕 分支不存在,创建新分支..." |
|||
mkdir .tmp_dist |
|||
cd .tmp_dist |
|||
git init |
|||
git checkout -b $PROD_BRANCH |
|||
git remote add origin $GIT_REPO_URL |
|||
cd .. |
|||
} |
|||
|
|||
# 3. 压缩构建产物 |
|||
echo "🗜️ 压缩构建目录..." |
|||
tar -czf "$ARCHIVE_PATH" -C "$BUILD_FOLDER" . |
|||
|
|||
# 4. 删除旧产物,复制新产物 |
|||
echo "♻️ 更新产物文件..." |
|||
rm -rf .tmp_dist/* |
|||
cp -r $BUILD_FOLDER/.drone.yml .tmp_dist/.drone.yml |
|||
cp "$ARCHIVE_PATH" ".tmp_dist/$ARCHIVE_NAME" |
|||
|
|||
# 5. 提交并推送 |
|||
cd .tmp_dist |
|||
git add -A |
|||
git commit -m "$COMMIT_MSG" |
|||
|
|||
echo "🚀 推送到远程分支 $PROD_BRANCH..." |
|||
git push origin $PROD_BRANCH |
|||
|
|||
# 6. 清理临时文件 |
|||
cd .. |
|||
rm -rf .tmp_dist |
|||
rm -f "$ARCHIVE_PATH" |
|||
|
|||
echo "✅ 发布完成!" |
|||
@ -0,0 +1,11 @@ |
|||
if [ -f .env.prod ]; then |
|||
cp .env.prod .output/.env |
|||
else |
|||
cp .env.example .output/.env |
|||
fi |
|||
|
|||
cp build-files/run.sh .output/run.sh |
|||
mkdir .output/server/migrate |
|||
cp -r build-files/migrate/* .output/server/migrate/ |
|||
mkdir .output/server/seed |
|||
cp -r build-files/* .output/server/seed/ |
|||
@ -0,0 +1,42 @@ |
|||
/** |
|||
* 各云平台 / 容器编排常见 HTTP 探针路径(不含 `/`,避免误伤站点根)。 |
|||
* 可按负载均衡控制台实际配置增删;若与业务路由同名请从列表中移除对应项。 |
|||
*/ |
|||
export const CLOUD_PROBE_PATHS = [ |
|||
// 通用 / Kubernetes
|
|||
"/health", |
|||
"/healthz", |
|||
"/livez", |
|||
"/readyz", |
|||
"/liveness", |
|||
"/readiness", |
|||
"/startup", |
|||
"/health/live", |
|||
"/health/ready", |
|||
"/health/startup", |
|||
// Spring Actuator(经网关暴露时偶见)
|
|||
"/actuator/health", |
|||
"/actuator/health/liveness", |
|||
"/actuator/health/readiness", |
|||
// Azure App Service 相关默认探测
|
|||
"/robots933456.txt", |
|||
// 国内云控制台常见示例静态页
|
|||
"/check.html", |
|||
"/status.html", |
|||
"/ping", |
|||
"/status", |
|||
"/alive", |
|||
] as const; |
|||
|
|||
/** 阿里云等:健康检查为 `/rpc` 或带后缀路径(如控制台填写的 `/rpc/...`),按前缀匹配 */ |
|||
export const CLOUD_PROBE_PATH_PREFIXES = ["/rpc"] as const; |
|||
|
|||
export const CLOUD_PROBE_PATH_SET = new Set<string>(CLOUD_PROBE_PATHS); |
|||
|
|||
export function isCloudProbePath(pathname: string): boolean { |
|||
if (CLOUD_PROBE_PATH_SET.has(pathname)) return true; |
|||
for (const prefix of CLOUD_PROBE_PATH_PREFIXES) { |
|||
if (pathname === prefix || pathname.startsWith(`${prefix}/`)) return true; |
|||
} |
|||
return false; |
|||
} |
|||
@ -0,0 +1,56 @@ |
|||
import path from "node:path"; |
|||
|
|||
function trimSlashes(input: string): string { |
|||
return input.trim().replace(/^\/+|\/+$/g, ""); |
|||
} |
|||
|
|||
function hasParentSegment(input: string): boolean { |
|||
return input |
|||
.split("/") |
|||
.map((part) => part.trim()) |
|||
.some((part) => part === ".."); |
|||
} |
|||
|
|||
/** 允许相对项目根或绝对路径;禁止含 `..` 的路径片段(防止配置逃逸)。 */ |
|||
function ensureConfigurableDir(input: string, fallback: string, envName: string): string { |
|||
const raw = input.trim(); |
|||
if (!raw) { |
|||
return fallback; |
|||
} |
|||
const normalized = path.normalize(raw); |
|||
const segments = normalized.split(path.sep); |
|||
if (segments.some((part) => part === "..")) { |
|||
throw new Error(`${envName} must not contain ".." path segments`); |
|||
} |
|||
return normalized; |
|||
} |
|||
|
|||
function ensureSafeSubdir(input: string, fallback: string, envName: string): string { |
|||
const value = trimSlashes(input); |
|||
if (!value) { |
|||
return fallback; |
|||
} |
|||
if (hasParentSegment(value)) { |
|||
throw new Error(`${envName} must not contain ".." segments`); |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
/** 静态资源 URL 前缀固定为 `/static`,不允许通过环境变量覆写。 */ |
|||
export const STATIC_PUBLIC_PREFIX = "/static"; |
|||
|
|||
/** 静态资源根目录(相对项目根或绝对路径),默认 `static` */ |
|||
export const STATIC_DIR = ensureConfigurableDir(process.env.STATIC_DIR ?? "static", "static", "STATIC_DIR"); |
|||
|
|||
/** 媒体上传子目录(相对 STATIC_DIR),默认 `upload` */ |
|||
export const UPLOAD_SUBDIR = ensureSafeSubdir( |
|||
process.env.UPLOAD_SUBDIR ?? "upload", |
|||
"upload", |
|||
"UPLOAD_SUBDIR", |
|||
); |
|||
|
|||
/** 媒体上传目录(与 STATIC_DIR 同为相对或绝对),默认 `static/upload` */ |
|||
export const RELATIVE_ASSETS_DIR = path.join(STATIC_DIR, UPLOAD_SUBDIR); |
|||
|
|||
/** 与 `media` 返回及静态路径一致,无前导 host */ |
|||
export const POST_MEDIA_PUBLIC_PREFIX = `${STATIC_PUBLIC_PREFIX}/${UPLOAD_SUBDIR}/`; |
|||
@ -0,0 +1,25 @@ |
|||
import { getRequestURL } from "h3"; |
|||
import { isCloudProbePath } from "#server/constants/cloud-probes"; |
|||
|
|||
const METHODS = new Set(["GET", "HEAD"]); |
|||
|
|||
export default eventHandler((event) => { |
|||
if (!METHODS.has(event.method)) return; |
|||
|
|||
const pathname = getRequestURL(event).pathname; |
|||
if (!isCloudProbePath(pathname)) return; |
|||
|
|||
setHeader(event, "content-type", "text/plain; charset=utf-8"); |
|||
setHeader( |
|||
event, |
|||
"cache-control", |
|||
"no-store, no-cache, must-revalidate, proxy-revalidate", |
|||
); |
|||
|
|||
if (event.method === "HEAD") { |
|||
setResponseStatus(event, 200); |
|||
return; |
|||
} |
|||
|
|||
return "OK"; |
|||
}); |
|||
@ -1,8 +0,0 @@ |
|||
|
|||
if (import.meta.dev) { |
|||
console.log("plugin: 00.global"); |
|||
} |
|||
|
|||
export default defineNitroPlugin(async () => { |
|||
|
|||
}) |
|||
@ -0,0 +1 @@ |
|||
sadsadsad |
|||
Loading…
Reference in new issue