Compare commits

..

7 Commits

Author SHA1 Message Date
Cuishibing
3f3e1c4878 init 2026-04-19 00:09:59 +08:00
Cuishibing
2d6a6ac22b feat: 图片上传优化及审核状态交互改进 2026-03-31 00:03:08 +08:00
Cuishibing
1b6e7fa886 feat: 添加房源审核功能及定时审核任务 2026-03-24 22:54:24 +08:00
Cuishibing
9998fb8649 fix: 优化图片上传URL和房东入口登录检查体验 2026-03-22 23:04:39 +08:00
Cuishibing
fbd5f94a43 feat: 切换为MariaDB数据库存储 2026-03-22 22:10:41 +08:00
Cuishibing
a105f4aecb docs: 添加项目文档 2026-03-22 22:01:21 +08:00
Cuishibing
3c6481811e fix: 修复SSH隧道端口转发和Cookie配置问题 2026-03-22 17:31:47 +08:00
8 changed files with 111 additions and 180 deletions

View File

@@ -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"]

View File

@@ -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
```
## 九、注意事项 ## 九、注意事项

View File

@@ -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 });
}
}

View File

@@ -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 });

View File

@@ -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

View File

@@ -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);

View File

@@ -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
View 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