feat: 添加生产环境配置,重构 API 请求,更新部署脚本和配置

This commit is contained in:
richarjiang
2026-03-15 23:00:51 +08:00
parent 3c35f1982f
commit 441cc8dd48
14 changed files with 131 additions and 20 deletions

24
.env.production Normal file
View File

@@ -0,0 +1,24 @@
# Database (MySQL) - Production
DATABASE_URL="mysql://root:MemeMind@2026@127.0.0.1:13306/mememind"
# Better Auth
BETTER_AUTH_SECRET="WU+pYbaBiDkBEzQBrdhsWAuZGmEX6mSFi9Lyw0C3BaI="
BETTER_AUTH_URL="https://ilookai.cn"
# Public URLs (for client-side)
NEXT_PUBLIC_APP_URL="https://ilookai.cn"
NEXT_PUBLIC_BASE_PATH="/studio"
# Admin Account
ADMIN_EMAIL="richardwei1995@gmail.com"
ADMIN_PASSWORD="richard_123456"
# Tencent COS
COS_SECRET_ID=AKIDTs7B2f5NVSFqIYaP1QbZQE0bAuc9h4EB
COS_SECRET_KEY=pRs6yiHcNyVs21pRq11nOlupY4OVPOH1
COS_BUCKET=lookai-1308511832
COS_REGION=ap-guangzhou
COS_APPID=1308511832
# Server Port
PORT=3001

View File

@@ -9,6 +9,7 @@ import { LevelDialog } from '@/components/levels/level-dialog'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { Level, LevelFormData } from '@/types' import { Level, LevelFormData } from '@/types'
import { Plus } from 'lucide-react' import { Plus } from 'lucide-react'
import { apiFetch } from '@/lib/api'
export default function LevelsPage() { export default function LevelsPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -20,7 +21,7 @@ export default function LevelsPage() {
const { data: levels, isLoading, error } = useQuery<Level[]>({ const { data: levels, isLoading, error } = useQuery<Level[]>({
queryKey: ['levels'], queryKey: ['levels'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/api/levels') const res = await apiFetch('/api/levels')
if (!res.ok) throw new Error('Failed to fetch levels') if (!res.ok) throw new Error('Failed to fetch levels')
return res.json() return res.json()
}, },
@@ -29,7 +30,7 @@ export default function LevelsPage() {
// Create level mutation // Create level mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: LevelFormData) => { mutationFn: async (data: LevelFormData) => {
const res = await fetch('/api/levels', { const res = await apiFetch('/api/levels', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -48,7 +49,7 @@ export default function LevelsPage() {
// Update level mutation // Update level mutation
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: LevelFormData }) => { mutationFn: async ({ id, data }: { id: string; data: LevelFormData }) => {
const res = await fetch('/api/levels', { const res = await apiFetch('/api/levels', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...data }), body: JSON.stringify({ id, ...data }),
@@ -67,7 +68,7 @@ export default function LevelsPage() {
// Delete level mutation // Delete level mutation
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
const res = await fetch(`/api/levels?id=${id}`, { const res = await apiFetch(`/api/levels?id=${id}`, {
method: 'DELETE', method: 'DELETE',
}) })
if (!res.ok) { if (!res.ok) {
@@ -85,7 +86,7 @@ export default function LevelsPage() {
// Reorder mutation // Reorder mutation
const reorderMutation = useMutation({ const reorderMutation = useMutation({
mutationFn: async (orders: { id: string; sortOrder: number }[]) => { mutationFn: async (orders: { id: string; sortOrder: number }[]) => {
const res = await fetch('/api/levels/reorder', { const res = await apiFetch('/api/levels/reorder', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orders }), body: JSON.stringify({ orders }),

View File

@@ -8,6 +8,7 @@ import { UserDialog } from '@/components/users/user-dialog'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import { User, UserFormData } from '@/types' import { User, UserFormData } from '@/types'
import { Plus, Pencil, Trash2 } from 'lucide-react' import { Plus, Pencil, Trash2 } from 'lucide-react'
import { apiFetch } from '@/lib/api'
export default function UsersPage() { export default function UsersPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -19,7 +20,7 @@ export default function UsersPage() {
const { data: users, isLoading, error } = useQuery<User[]>({ const { data: users, isLoading, error } = useQuery<User[]>({
queryKey: ['users'], queryKey: ['users'],
queryFn: async () => { queryFn: async () => {
const res = await fetch('/api/users') const res = await apiFetch('/api/users')
if (!res.ok) throw new Error('Failed to fetch users') if (!res.ok) throw new Error('Failed to fetch users')
return res.json() return res.json()
}, },
@@ -28,7 +29,7 @@ export default function UsersPage() {
// Create user mutation // Create user mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: async (data: UserFormData) => { mutationFn: async (data: UserFormData) => {
const res = await fetch('/api/users', { const res = await apiFetch('/api/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -47,7 +48,7 @@ export default function UsersPage() {
// Update user mutation // Update user mutation
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: UserFormData }) => { mutationFn: async ({ id, data }: { id: string; data: UserFormData }) => {
const res = await fetch('/api/users', { const res = await apiFetch('/api/users', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...data }), body: JSON.stringify({ id, ...data }),
@@ -66,7 +67,7 @@ export default function UsersPage() {
// Delete user mutation // Delete user mutation
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
const res = await fetch(`/api/users?id=${id}`, { const res = await apiFetch(`/api/users?id=${id}`, {
method: 'DELETE', method: 'DELETE',
}) })
if (!res.ok) { if (!res.ok) {

View File

@@ -6,6 +6,7 @@ import { X, Image as ImageIcon } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { Spinner } from '@/components/ui/spinner' import { Spinner } from '@/components/ui/spinner'
import COS from 'cos-js-sdk-v5' import COS from 'cos-js-sdk-v5'
import { apiFetch } from '@/lib/api'
interface ImageUploaderProps { interface ImageUploaderProps {
value: string value: string
@@ -38,7 +39,7 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) {
try { try {
// Get temp key // Get temp key
const keyRes = await fetch('/api/cos/temp-key') const keyRes = await apiFetch('/api/cos/temp-key')
if (!keyRes.ok) { if (!keyRes.ok) {
throw new Error('获取上传凭证失败') throw new Error('获取上传凭证失败')
} }

47
deploy.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -euo pipefail
# ─── 配置 ───────────────────────────────────────────
SERVER="root@119.91.211.52"
REMOTE_DIR="/root/apps/meme-studio"
APP_NAME="meme-studio"
# ─── 颜色 ───────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
NC='\033[0m'
step() { echo -e "\n${GREEN}$1${NC}"; }
warn() { echo -e "${YELLOW}$1${NC}"; }
fail() { echo -e "${RED}$1${NC}"; exit 1; }
START_TIME=$(date +%s)
# ─── 1. 本地构建 ────────────────────────────────────
step "构建项目..."
pnpm run build || fail "构建失败"
# ─── 2. 创建远程目录 ────────────────────────────────
step "初始化远程目录..."
ssh "$SERVER" "mkdir -p $REMOTE_DIR/.next/static $REMOTE_DIR/public"
# ─── 3. 同步文件(排除 node_modules由服务器安装───
step "同步文件到服务器..."
rsync -az --delete --exclude='node_modules' .next/standalone/ "$SERVER:$REMOTE_DIR/"
rsync -az .next/static/ "$SERVER:$REMOTE_DIR/.next/static/"
rsync -az public/ "$SERVER:$REMOTE_DIR/public/"
rsync -az prisma/ "$SERVER:$REMOTE_DIR/prisma/"
rsync -az .env.production "$SERVER:$REMOTE_DIR/.env"
rsync -az ecosystem.config.js "$SERVER:$REMOTE_DIR/"
rsync -az package.json package-lock.json "$SERVER:$REMOTE_DIR/"
# ─── 4. 远程安装依赖 & 重启 ─────────────────────────
step "安装依赖并重启服务..."
ssh "$SERVER" "cd $REMOTE_DIR && npm install --production && npx prisma generate && (pm2 restart $APP_NAME 2>/dev/null || pm2 start ecosystem.config.js)"
# ─── 完成 ───────────────────────────────────────────
END_TIME=$(date +%s)
ELAPSED=$((END_TIME - START_TIME))
echo -e "\n${GREEN}✔ 部署完成!耗时 ${ELAPSED}s${NC}"

18
ecosystem.config.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
apps: [
{
name: 'meme-studio',
script: 'server.js',
cwd: '/root/apps/meme-studio',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '1G',
env: {
NODE_ENV: 'production',
PORT: 3001,
HOSTNAME: '0.0.0.0',
},
},
],
}

6
lib/api.ts Normal file
View File

@@ -0,0 +1,6 @@
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''
export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
const url = input.startsWith('/') ? `${basePath}${input}` : input
return fetch(url, init)
}

View File

@@ -1,7 +1,10 @@
import { createAuthClient } from 'better-auth/react' import { createAuthClient } from 'better-auth/react'
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '/studio'
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3001'
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000', baseURL: `${appUrl}${basePath}/api/auth`,
}) })
export const { signIn, signOut, useSession } = authClient export const { signIn, signOut, useSession } = authClient

View File

@@ -9,7 +9,8 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
}, },
trustedOrigins: [process.env.BETTER_AUTH_URL || 'http://localhost:3000'], basePath: '/api/auth',
trustedOrigins: [process.env.BETTER_AUTH_URL || 'http://localhost:3001'],
secret: process.env.BETTER_AUTH_SECRET, secret: process.env.BETTER_AUTH_SECRET,
session: { session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days expiresIn: 60 * 60 * 24 * 7, // 7 days

View File

@@ -1,11 +1,14 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
const basePath = '/studio'
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
// Allow auth API routes and static files // Allow all API routes and static files
// Note: pathname does NOT include basePath when basePath is configured
if ( if (
pathname.startsWith('/api/auth') || pathname.startsWith('/api/') ||
pathname.startsWith('/_next') || pathname.startsWith('/_next') ||
pathname.startsWith('/favicon') || pathname.startsWith('/favicon') ||
pathname.includes('.') pathname.includes('.')
@@ -19,10 +22,12 @@ export async function middleware(request: NextRequest) {
} }
// Check if session cookie exists (simple check, full validation happens in server) // Check if session cookie exists (simple check, full validation happens in server)
// Better Auth adds "__Secure-" prefix when served over HTTPS
const sessionToken = request.cookies.get('better-auth.session_token') const sessionToken = request.cookies.get('better-auth.session_token')
|| request.cookies.get('__Secure-better-auth.session_token')
if (!sessionToken?.value) { if (!sessionToken?.value) {
const loginUrl = new URL('/login', request.url) const loginUrl = new URL(`${basePath}/login`, request.url)
loginUrl.searchParams.set('callbackUrl', pathname) loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl) return NextResponse.redirect(loginUrl)
} }
@@ -31,5 +36,5 @@ export async function middleware(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ['/((?!api/auth|_next/static|_next/image|favicon.ico).*)'], matcher: '/:path*',
} }

View File

@@ -1,5 +1,7 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone',
basePath: '/studio',
images: { images: {
remotePatterns: [ remotePatterns: [
{ {

View File

@@ -3,15 +3,16 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p 3001",
"lint": "next lint", "lint": "next lint",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts" "db:seed": "tsx prisma/seed.ts",
"deploy": "./deploy.sh"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",

View File

@@ -3,6 +3,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "rhel-openssl-3.0.x"]
} }
datasource db { datasource db {

0
public/.gitkeep Normal file
View File