feat: 添加生产环境配置,重构 API 请求,更新部署脚本和配置
This commit is contained in:
24
.env.production
Normal file
24
.env.production
Normal 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
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
47
deploy.sh
Executable 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
18
ecosystem.config.js
Normal 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
6
lib/api.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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*',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
basePath: '/studio',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||||
|
|
||||||
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
0
public/.gitkeep
Normal file
Reference in New Issue
Block a user