Compare commits
18 Commits
3f3e1c4878
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1b8abf414 | ||
|
|
f847e1c6c6 | ||
|
|
7c8cea2af7 | ||
|
|
dfc9ef3e97 | ||
|
|
6bf666d250 | ||
|
|
d6017c7389 | ||
|
|
98eb1706ed | ||
|
|
6e5cfeb960 | ||
|
|
3b241cd0d4 | ||
|
|
0c03f95729 | ||
|
|
9263f7f460 | ||
|
|
73c6a779e0 | ||
|
|
99e33b3b40 | ||
|
|
b5e04ee3e4 | ||
|
|
d5ab66d84e | ||
|
|
6262bbb04b | ||
|
|
9bd202fc90 | ||
|
|
762f928e5e |
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
11
PROJECT.md
11
PROJECT.md
@@ -141,16 +141,11 @@ npm run dev
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
# 默认端口3000,可通过 PORT=3001 npm start 指定端口
|
||||
```
|
||||
|
||||
### 8.3 远程访问
|
||||
SSH反向隧道将远程30000端口映射到本地3000:
|
||||
```bash
|
||||
./port_forwarding.sh start # 启动
|
||||
./port_forwarding.sh stop # 停止
|
||||
./port_forwarding.sh status # 状态
|
||||
# 访问 http://47.120.74.73:30000
|
||||
```
|
||||
### 8.3 图片服务(规划中)
|
||||
计划将图片上传独立出来,新建 `smalltown-upload` 项目处理图片存储和压缩。
|
||||
|
||||
## 九、注意事项
|
||||
|
||||
|
||||
31
app/api/files/[fileKey]/route.ts
Normal file
31
app/api/files/[fileKey]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const OSS_URL = process.env.OSS_URL || 'http://localhost:9000';
|
||||
const API_KEY = process.env.OSS_API_KEY || '7cf93760ea49b750c96e6078b364e5f0';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ fileKey: string }> }
|
||||
) {
|
||||
try {
|
||||
const { fileKey } = await params;
|
||||
|
||||
const res = await fetch(`${OSS_URL}/api/files/${fileKey}/preview?key=${API_KEY}`);
|
||||
|
||||
if (!res.ok) {
|
||||
return new NextResponse('File not found', { status: 404 });
|
||||
}
|
||||
|
||||
const imageBuffer = await res.arrayBuffer();
|
||||
|
||||
return new NextResponse(imageBuffer, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
return new NextResponse('Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const UPLOAD_DIR = path.join(process.cwd(), 'public/uploads');
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const OSS_URL = process.env.OSS_URL || 'http://localhost:9000';
|
||||
const API_KEY = process.env.OSS_API_KEY || '7cf93760ea49b750c96e6078b364e5f0';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let imageBuffer: Buffer;
|
||||
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
@@ -20,29 +21,21 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '仅支持 JPG、PNG、GIF、WebP 格式' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ext = 'jpg';
|
||||
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
||||
const filepath = path.join(UPLOAD_DIR, filename);
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
let imageBuffer = Buffer.from(buffer);
|
||||
imageBuffer = Buffer.from(buffer);
|
||||
|
||||
// 获取图片尺寸
|
||||
const image = sharp(imageBuffer);
|
||||
const metadata = await image.metadata();
|
||||
|
||||
// 如果超过5MB,进行压缩
|
||||
if (imageBuffer.length > MAX_SIZE) {
|
||||
let quality = 85;
|
||||
|
||||
// 先尝试缩小尺寸
|
||||
if (metadata.width && metadata.width > 1920) {
|
||||
imageBuffer = await image
|
||||
.resize(1920, null, { withoutEnlargement: true })
|
||||
.toBuffer() as any;
|
||||
}
|
||||
|
||||
// 循环压缩直到小于5MB
|
||||
while (imageBuffer.length > MAX_SIZE && quality > 30) {
|
||||
imageBuffer = await sharp(imageBuffer)
|
||||
.jpeg({ quality, progressive: true })
|
||||
@@ -50,7 +43,6 @@ export async function POST(request: NextRequest) {
|
||||
quality -= 10;
|
||||
}
|
||||
|
||||
// 如果还是太大,继续缩小尺寸
|
||||
if (imageBuffer.length > MAX_SIZE && metadata.width && metadata.width > 800) {
|
||||
imageBuffer = await sharp(imageBuffer)
|
||||
.resize(800, null, { withoutEnlargement: true })
|
||||
@@ -58,17 +50,34 @@ export async function POST(request: NextRequest) {
|
||||
.toBuffer() as any;
|
||||
}
|
||||
} else {
|
||||
// 转换为jpeg并优化
|
||||
imageBuffer = await sharp(imageBuffer)
|
||||
.jpeg({ quality: 85, progressive: true })
|
||||
.toBuffer() as any;
|
||||
}
|
||||
|
||||
await fs.writeFile(filepath, imageBuffer);
|
||||
const formData2 = new FormData();
|
||||
const uint8Array = new Uint8Array(imageBuffer);
|
||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
||||
formData2.append('file', blob, 'image.jpg');
|
||||
|
||||
// 返回相对路径
|
||||
const url = `/uploads/${filename}`;
|
||||
return NextResponse.json({ url });
|
||||
const uploadRes = await fetch(`${OSS_URL}/api/files`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-api-key': API_KEY,
|
||||
},
|
||||
body: formData2,
|
||||
});
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const err = await uploadRes.text();
|
||||
console.error('OSS upload failed:', err);
|
||||
return NextResponse.json({ error: '上传失败' }, { status: 500 });
|
||||
}
|
||||
|
||||
const fileData = await uploadRes.json();
|
||||
|
||||
const url = `/api/files/${fileData.fileKey}`;
|
||||
return NextResponse.json({ url, fileKey: fileData.fileKey });
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
return NextResponse.json({ error: '上传失败' }, { status: 500 });
|
||||
@@ -78,16 +87,24 @@ export async function POST(request: NextRequest) {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const filename = searchParams.get('filename');
|
||||
const fileKey = searchParams.get('fileKey');
|
||||
|
||||
if (!filename) {
|
||||
return NextResponse.json({ error: '缺少文件名' }, { status: 400 });
|
||||
if (!fileKey) {
|
||||
return NextResponse.json({ error: '缺少文件标识' }, { status: 400 });
|
||||
}
|
||||
|
||||
const filepath = path.join(UPLOAD_DIR, path.basename(filename));
|
||||
await fs.unlink(filepath);
|
||||
const res = await fetch(`${OSS_URL}/api/files/${fileKey}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-api-key': API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
if (res.ok) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: '删除失败' }, { status: 500 });
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
return NextResponse.json({ error: '删除失败' }, { status: 500 });
|
||||
|
||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:10.11
|
||||
container_name: smalltown_mysql
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpass
|
||||
MYSQL_DATABASE: smalltown
|
||||
MYSQL_USER: smalltown
|
||||
MYSQL_PASSWORD: MyPassword1+
|
||||
ports:
|
||||
- "9001:3306"
|
||||
volumes:
|
||||
- /home/cui/mysql_data:/var/lib/mysql
|
||||
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
smalltown:
|
||||
build: .
|
||||
container_name: smalltown_app
|
||||
restart: always
|
||||
ports:
|
||||
- "3001:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_HOST=mysql
|
||||
- DB_PORT=3306
|
||||
- DB_USER=smalltown
|
||||
- DB_PASSWORD=MyPassword1+
|
||||
- DB_NAME=smalltown
|
||||
- OSS_URL=http://smalltown.dubaoda.com:9000
|
||||
- OSS_API_KEY=b3302a486353f762646a9073020f3036
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
45
init.sql
Normal file
45
init.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- 创建数据库(如果不存在)
|
||||
CREATE DATABASE IF NOT EXISTS smalltown;
|
||||
USE smalltown;
|
||||
|
||||
-- 用户表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(64) NOT NULL,
|
||||
token VARCHAR(64),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_token (token),
|
||||
INDEX idx_username (username)
|
||||
);
|
||||
|
||||
-- 房屋表
|
||||
CREATE TABLE IF NOT EXISTS houses (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
owner VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT,
|
||||
price INT NOT NULL,
|
||||
district VARCHAR(50) NOT NULL,
|
||||
address VARCHAR(500) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
images JSON,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
reject_reason VARCHAR(500),
|
||||
reviewed_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_owner (owner),
|
||||
INDEX idx_district (district),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created (created_at)
|
||||
);
|
||||
|
||||
-- 地区表
|
||||
CREATE TABLE IF NOT EXISTS districts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL UNIQUE,
|
||||
sort_order INT DEFAULT 0
|
||||
);
|
||||
|
||||
-- 初始化地区数据
|
||||
INSERT INTO districts (name, sort_order) VALUES ('北京市-奶东村', 1);
|
||||
10
lib/db.ts
10
lib/db.ts
@@ -1,11 +1,11 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: '192.168.0.196',
|
||||
port: 3306,
|
||||
user: 'smalltown',
|
||||
password: 'MyPassword1+',
|
||||
database: 'smalltown',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
user: process.env.DB_USER || 'smalltown',
|
||||
password: process.env.DB_PASSWORD || 'MyPassword1+',
|
||||
database: process.env.DB_NAME || 'smalltown',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
REMOTE_HOST="smalltown.dubaoda.com"
|
||||
REMOTE_PORT="30000"
|
||||
LOCAL_PORT="3000"
|
||||
SSH_PORT="9527"
|
||||
SSH_USER="cui"
|
||||
SSH_PASSWORD="mima643237029"
|
||||
PID_FILE="/tmp/ssh_tunnel.pid"
|
||||
LOG_FILE="/tmp/ssh_tunnel.log"
|
||||
|
||||
start_tunnel() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
OLD_PID=$(cat "$PID_FILE")
|
||||
if kill -0 "$OLD_PID" 2>/dev/null; then
|
||||
echo "SSH隧道已在运行 (PID: $OLD_PID)"
|
||||
return
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
|
||||
echo "正在启动SSH反向隧道..."
|
||||
sshpass -p "$SSH_PASSWORD" ssh -N -g -R "0.0.0.0:$REMOTE_PORT":localhost:"$LOCAL_PORT" -p "$SSH_PORT" -o "ServerAliveInterval=60" -o "ServerAliveCountMax=3" "$SSH_USER"@"$REMOTE_HOST" > "$LOG_FILE" 2>&1 &
|
||||
SSH_PID=$!
|
||||
echo "$SSH_PID" > "$PID_FILE"
|
||||
sleep 2
|
||||
|
||||
if kill -0 "$SSH_PID" 2>/dev/null; then
|
||||
echo "SSH隧道已启动 (本地3000 -> 远程$REMOTE_PORT)"
|
||||
else
|
||||
echo "启动失败,查看日志: cat $LOG_FILE"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_tunnel() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
kill "$PID"
|
||||
rm -f "$PID_FILE"
|
||||
echo "SSH隧道已停止"
|
||||
else
|
||||
rm -f "$PID_FILE"
|
||||
echo "隧道未运行"
|
||||
fi
|
||||
else
|
||||
echo "未找到PID文件,尝试直接终止"
|
||||
pkill -f "ssh -N -R $REMOTE_PORT:localhost:$LOCAL_PORT"
|
||||
fi
|
||||
}
|
||||
|
||||
status_tunnel() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "SSH隧道正在运行 (PID: $PID)"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
echo "SSH隧道未运行"
|
||||
echo "尝试检测进程..."
|
||||
if pgrep -f "ssh -N -R $REMOTE_PORT" > /dev/null; then
|
||||
echo "发现隧道进程,但无PID文件"
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start) start_tunnel ;;
|
||||
stop) stop_tunnel ;;
|
||||
status) status_tunnel ;;
|
||||
*) echo "Usage: $0 {start|stop|status}" ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user