Compare commits
7 Commits
main
...
3f3e1c4878
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f3e1c4878 | ||
|
|
2d6a6ac22b | ||
|
|
1b6e7fa886 | ||
|
|
9998fb8649 | ||
|
|
fbd5f94a43 | ||
|
|
a105f4aecb | ||
|
|
3c6481811e |
13
Dockerfile
13
Dockerfile
@@ -1,13 +0,0 @@
|
|||||||
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,11 +141,16 @@ npm run dev
|
|||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
npm start
|
npm start
|
||||||
# 默认端口3000,可通过 PORT=3001 npm start 指定端口
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.3 图片服务(规划中)
|
### 8.3 远程访问
|
||||||
计划将图片上传独立出来,新建 `smalltown-upload` 项目处理图片存储和压缩。
|
SSH反向隧道将远程30000端口映射到本地3000:
|
||||||
|
```bash
|
||||||
|
./port_forwarding.sh start # 启动
|
||||||
|
./port_forwarding.sh stop # 停止
|
||||||
|
./port_forwarding.sh status # 状态
|
||||||
|
# 访问 http://47.120.74.73:30000
|
||||||
|
```
|
||||||
|
|
||||||
## 九、注意事项
|
## 九、注意事项
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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,13 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const UPLOAD_DIR = path.join(process.cwd(), 'public/uploads');
|
||||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
let imageBuffer: Buffer;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get('file') as File | null;
|
const file = formData.get('file') as File | null;
|
||||||
@@ -21,21 +20,29 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: '仅支持 JPG、PNG、GIF、WebP 格式' }, { status: 400 });
|
return NextResponse.json({ error: '仅支持 JPG、PNG、GIF、WebP 格式' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = await file.arrayBuffer();
|
const ext = 'jpg';
|
||||||
imageBuffer = Buffer.from(buffer);
|
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);
|
||||||
|
|
||||||
|
// 获取图片尺寸
|
||||||
const image = sharp(imageBuffer);
|
const image = sharp(imageBuffer);
|
||||||
const metadata = await image.metadata();
|
const metadata = await image.metadata();
|
||||||
|
|
||||||
|
// 如果超过5MB,进行压缩
|
||||||
if (imageBuffer.length > MAX_SIZE) {
|
if (imageBuffer.length > MAX_SIZE) {
|
||||||
let quality = 85;
|
let quality = 85;
|
||||||
|
|
||||||
|
// 先尝试缩小尺寸
|
||||||
if (metadata.width && metadata.width > 1920) {
|
if (metadata.width && metadata.width > 1920) {
|
||||||
imageBuffer = await image
|
imageBuffer = await image
|
||||||
.resize(1920, null, { withoutEnlargement: true })
|
.resize(1920, null, { withoutEnlargement: true })
|
||||||
.toBuffer() as any;
|
.toBuffer() as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 循环压缩直到小于5MB
|
||||||
while (imageBuffer.length > MAX_SIZE && quality > 30) {
|
while (imageBuffer.length > MAX_SIZE && quality > 30) {
|
||||||
imageBuffer = await sharp(imageBuffer)
|
imageBuffer = await sharp(imageBuffer)
|
||||||
.jpeg({ quality, progressive: true })
|
.jpeg({ quality, progressive: true })
|
||||||
@@ -43,6 +50,7 @@ export async function POST(request: NextRequest) {
|
|||||||
quality -= 10;
|
quality -= 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果还是太大,继续缩小尺寸
|
||||||
if (imageBuffer.length > MAX_SIZE && metadata.width && metadata.width > 800) {
|
if (imageBuffer.length > MAX_SIZE && metadata.width && metadata.width > 800) {
|
||||||
imageBuffer = await sharp(imageBuffer)
|
imageBuffer = await sharp(imageBuffer)
|
||||||
.resize(800, null, { withoutEnlargement: true })
|
.resize(800, null, { withoutEnlargement: true })
|
||||||
@@ -50,34 +58,17 @@ export async function POST(request: NextRequest) {
|
|||||||
.toBuffer() as any;
|
.toBuffer() as any;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// 转换为jpeg并优化
|
||||||
imageBuffer = await sharp(imageBuffer)
|
imageBuffer = await sharp(imageBuffer)
|
||||||
.jpeg({ quality: 85, progressive: true })
|
.jpeg({ quality: 85, progressive: true })
|
||||||
.toBuffer() as any;
|
.toBuffer() as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData2 = new FormData();
|
await fs.writeFile(filepath, imageBuffer);
|
||||||
const uint8Array = new Uint8Array(imageBuffer);
|
|
||||||
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
|
|
||||||
formData2.append('file', blob, 'image.jpg');
|
|
||||||
|
|
||||||
const uploadRes = await fetch(`${OSS_URL}/api/files`, {
|
// 返回相对路径
|
||||||
method: 'POST',
|
const url = `/uploads/${filename}`;
|
||||||
headers: {
|
return NextResponse.json({ url });
|
||||||
'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) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
return NextResponse.json({ error: '上传失败' }, { status: 500 });
|
return NextResponse.json({ error: '上传失败' }, { status: 500 });
|
||||||
@@ -87,24 +78,16 @@ export async function POST(request: NextRequest) {
|
|||||||
export async function DELETE(request: NextRequest) {
|
export async function DELETE(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const fileKey = searchParams.get('fileKey');
|
const filename = searchParams.get('filename');
|
||||||
|
|
||||||
if (!fileKey) {
|
if (!filename) {
|
||||||
return NextResponse.json({ error: '缺少文件标识' }, { status: 400 });
|
return NextResponse.json({ error: '缺少文件名' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`${OSS_URL}/api/files/${fileKey}`, {
|
const filepath = path.join(UPLOAD_DIR, path.basename(filename));
|
||||||
method: 'DELETE',
|
await fs.unlink(filepath);
|
||||||
headers: {
|
|
||||||
'x-api-key': API_KEY,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (res.ok) {
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: '删除失败' }, { status: 500 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete error:', error);
|
console.error('Delete error:', error);
|
||||||
return NextResponse.json({ error: '删除失败' }, { status: 500 });
|
return NextResponse.json({ error: '删除失败' }, { status: 500 });
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
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
45
init.sql
@@ -1,45 +0,0 @@
|
|||||||
-- 创建数据库(如果不存在)
|
|
||||||
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';
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST || 'localhost',
|
host: '192.168.0.196',
|
||||||
port: parseInt(process.env.DB_PORT || '3306'),
|
port: 3306,
|
||||||
user: process.env.DB_USER || 'smalltown',
|
user: 'smalltown',
|
||||||
password: process.env.DB_PASSWORD || 'MyPassword1+',
|
password: 'MyPassword1+',
|
||||||
database: process.env.DB_NAME || 'smalltown',
|
database: 'smalltown',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0
|
queueLimit: 0
|
||||||
|
|||||||
73
port_forwarding.sh
Executable file
73
port_forwarding.sh
Executable file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/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