Compare commits

..

11 Commits

Author SHA1 Message Date
Cuishibing
f1b8abf414 feat: 添加图片代理接口,保护OSS API key 2026-04-26 21:57:37 +08:00
Cuishibing
f847e1c6c6 fix: 预览URL添加API key参数 2026-04-26 21:50:44 +08:00
Cuishibing
7c8cea2af7 fix: 修复my_oss API路径,添加/api前缀 2026-04-26 21:39:40 +08:00
Cuishibing
dfc9ef3e97 fix: 更新OSS API Key 2026-04-26 21:24:29 +08:00
Cuishibing
6bf666d250 feat: 添加数据库初始化SQL 2026-04-26 21:14:44 +08:00
Cuishibing
d6017c7389 fix: 使用mariadb镜像 2026-04-26 21:13:10 +08:00
Cuishibing
98eb1706ed fix: 修复docker-compose配置 2026-04-26 21:04:50 +08:00
Cuishibing
6e5cfeb960 feat: Docker配置优化,指定端口和数据目录 2026-04-26 21:03:07 +08:00
Cuishibing
3b241cd0d4 feat: Docker配置优化,MySQL数据持久化到本地 2026-04-26 20:57:29 +08:00
Cuishibing
0c03f95729 feat: 添加Docker配置,集成my_oss图片服务 2026-04-26 20:44:55 +08:00
Cuishibing
9263f7f460 feat: 图片上传改用my_oss服务 2026-04-26 20:19:44 +08:00
7 changed files with 179 additions and 37 deletions

13
Dockerfile Normal file
View 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"]

View File

@@ -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` 项目处理图片存储和压缩。
## 九、注意事项

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

View File

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

View File

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