Compare commits
11 Commits
73c6a779e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1b8abf414 | ||
|
|
f847e1c6c6 | ||
|
|
7c8cea2af7 | ||
|
|
dfc9ef3e97 | ||
|
|
6bf666d250 | ||
|
|
d6017c7389 | ||
|
|
98eb1706ed | ||
|
|
6e5cfeb960 | ||
|
|
3b241cd0d4 | ||
|
|
0c03f95729 | ||
|
|
9263f7f460 |
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
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
npm start
|
npm start
|
||||||
|
# 默认端口3000,可通过 PORT=3001 npm start 指定端口
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8.3 远程访问
|
### 8.3 图片服务(规划中)
|
||||||
SSH反向隧道将远程30000端口映射到本地3000:
|
计划将图片上传独立出来,新建 `smalltown-upload` 项目处理图片存储和压缩。
|
||||||
```bash
|
|
||||||
./port_forwarding.sh start # 启动
|
|
||||||
./port_forwarding.sh stop # 停止
|
|
||||||
./port_forwarding.sh status # 状态
|
|
||||||
# 访问 http://47.120.74.73:30000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 九、注意事项
|
## 九、注意事项
|
||||||
|
|
||||||
|
|||||||
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 { 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;
|
||||||
@@ -20,29 +21,21 @@ 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 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();
|
const buffer = await file.arrayBuffer();
|
||||||
let imageBuffer = Buffer.from(buffer);
|
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 })
|
||||||
@@ -50,7 +43,6 @@ 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 })
|
||||||
@@ -58,17 +50,34 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 uploadRes = await fetch(`${OSS_URL}/api/files`, {
|
||||||
const url = `/uploads/${filename}`;
|
method: 'POST',
|
||||||
return NextResponse.json({ url });
|
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) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
return NextResponse.json({ error: '上传失败' }, { status: 500 });
|
return NextResponse.json({ error: '上传失败' }, { status: 500 });
|
||||||
@@ -78,16 +87,24 @@ 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 filename = searchParams.get('filename');
|
const fileKey = searchParams.get('fileKey');
|
||||||
|
|
||||||
if (!filename) {
|
if (!fileKey) {
|
||||||
return NextResponse.json({ error: '缺少文件名' }, { status: 400 });
|
return NextResponse.json({ error: '缺少文件标识' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filepath = path.join(UPLOAD_DIR, path.basename(filename));
|
const res = await fetch(`${OSS_URL}/api/files/${fileKey}`, {
|
||||||
await fs.unlink(filepath);
|
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) {
|
} catch (error) {
|
||||||
console.error('Delete error:', error);
|
console.error('Delete error:', error);
|
||||||
return NextResponse.json({ error: '删除失败' }, { status: 500 });
|
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';
|
import mysql from 'mysql2/promise';
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: '192.168.0.196',
|
host: process.env.DB_HOST || 'localhost',
|
||||||
port: 3306,
|
port: parseInt(process.env.DB_PORT || '3306'),
|
||||||
user: 'smalltown',
|
user: process.env.DB_USER || 'smalltown',
|
||||||
password: 'MyPassword1+',
|
password: process.env.DB_PASSWORD || 'MyPassword1+',
|
||||||
database: 'smalltown',
|
database: process.env.DB_NAME || 'smalltown',
|
||||||
waitForConnections: true,
|
waitForConnections: true,
|
||||||
connectionLimit: 10,
|
connectionLimit: 10,
|
||||||
queueLimit: 0
|
queueLimit: 0
|
||||||
|
|||||||
Reference in New Issue
Block a user