Compare commits

...

7 Commits

Author SHA1 Message Date
Cuishibing
73c6a779e0 init 2026-04-19 00:09:59 +08:00
Cuishibing
99e33b3b40 feat: 图片上传优化及审核状态交互改进 2026-03-31 00:03:08 +08:00
Cuishibing
b5e04ee3e4 feat: 添加房源审核功能及定时审核任务 2026-03-24 22:54:24 +08:00
Cuishibing
d5ab66d84e fix: 优化图片上传URL和房东入口登录检查体验 2026-03-22 23:04:39 +08:00
Cuishibing
6262bbb04b feat: 切换为MariaDB数据库存储 2026-03-22 22:10:41 +08:00
Cuishibing
9bd202fc90 docs: 添加项目文档 2026-03-22 22:01:21 +08:00
Cuishibing
762f928e5e fix: 修复SSH隧道端口转发和Cookie配置问题 2026-03-22 17:31:47 +08:00
16 changed files with 751 additions and 326 deletions

160
PROJECT.md Normal file
View File

@@ -0,0 +1,160 @@
# 城中村租房平台 - 项目文档
## 一、项目背景与目标
### 1.1 项目背景
城中村租房信息平台,为房东和租客提供简洁的租房信息服务。
### 1.2 项目目标
- 租客端:无需登录即可浏览、搜索房源,查看详情并联系房东
- 房东端:注册/登录后发布、管理房源,支持图片上传
- 移动端优先:适配手机访问
## 二、技术栈
| 类型 | 技术 |
|------|------|
| 框架 | Next.js 16 + TypeScript |
| 样式 | TailwindCSS |
| 数据库 | MariaDB 10 (192.168.0.196:3306) |
| 图片存储 | 本地文件系统 (public/uploads/) |
## 三、工程结构
```
smalltown2/
├── app/ # Next.js App Router
│ ├── api/ # API接口
│ │ ├── auth/ # 认证相关
│ │ │ ├── login/route.ts # 登录
│ │ │ ├── register/route.ts# 注册
│ │ │ └── me/route.ts # 当前用户
│ │ ├── houses/ # 房屋CRUD
│ │ │ ├── route.ts # 列表/创建
│ │ │ └── [id]/route.ts # 详情/更新/删除
│ │ ├── owner/houses/route.ts# 房东房源管理
│ │ ├── districts/route.ts # 地区列表
│ │ └── upload/route.ts # 图片上传
│ ├── house/[id]/page.tsx # 房屋详情页(租客)
│ ├── owner/ # 房东登录页
│ ├── owner/dashboard/ # 房东管理后台
│ └── page.tsx # 首页(租客浏览)
├── lib/
│ └── db.ts # 数据库连接池
├── public/uploads/ # 上传的图片
├── port_forwarding.sh # SSH隧道脚本
└── data/ # 本地数据已弃用data/已加入.gitignore
```
## 四、数据库表结构
### 4.1 users 表
```sql
CREATE TABLE 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
);
```
### 4.2 houses 表
```sql
CREATE TABLE 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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 4.3 districts 表
```sql
CREATE TABLE districts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
sort_order INT DEFAULT 0
);
```
## 五、页面路由
| 路径 | 说明 |
|------|------|
| `/` | 租客首页 - 浏览房源列表 |
| `/house/[id]` | 房屋详情页(租客) |
| `/owner` | 房东登录/注册页 |
| `/owner/dashboard` | 房东管理后台 |
## 六、API接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/register` | 房东注册 |
| POST | `/api/auth/login` | 房东登录 |
| GET | `/api/auth/me` | 获取当前用户 |
| DELETE | `/api/auth/me` | 登出 |
| GET | `/api/houses` | 获取房源列表支持district/keyword筛选 |
| POST | `/api/houses` | 创建房源(需登录) |
| GET | `/api/houses/[id]` | 获取房源详情 |
| PUT | `/api/houses/[id]` | 更新房源(需登录,房东只能修改自己的) |
| DELETE | `/api/houses/[id]` | 删除房源(需登录,房东只能删除自己的) |
| GET | `/api/owner/houses` | 获取房东自己的房源(需登录) |
| GET | `/api/districts` | 获取地区列表 |
| POST | `/api/upload` | 上传图片 |
## 七、已完成功能
### 7.1 租客端
- [x] 房源列表浏览(按地区筛选、关键词搜索)
- [x] 房屋详情查看
- [x] 联系房东(显示电话)
- [x] 移动端UI优化
### 7.2 房东端
- [x] 用户注册/登录Cookie认证
- [x] 发布新房源
- [x] 编辑/删除自己的房源
- [x] 图片上传(文件选择/摄像头拍照)
### 7.3 系统
- [x] MariaDB数据库集成
- [x] 图片本地存储
- [x] SSH端口转发脚本远程访问
## 八、部署说明
### 8.1 本地开发
```bash
npm run dev
# 访问 http://localhost:3000
```
### 8.2 生产构建
```bash
npm run build
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
```
## 九、注意事项
1. 数据库连接配置在 `lib/db.ts`,生产环境注意安全
2. 上传的图片存储在 `public/uploads/` 目录,已加入 .gitignore
3. data/ 目录已加入 .gitignore旧的JSON存储方式
4. Cookie设置 `secure: false` 以支持HTTP访问

View File

@@ -1,26 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
const USERS_FILE = path.join(process.cwd(), 'data/users.json');
interface User {
id: string;
username: string;
passwordHash: string;
token: string;
createdAt: string;
}
async function readUsers(): Promise<User[]> {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
import pool from '@/lib/db';
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
@@ -31,6 +11,7 @@ function generateToken(): string {
}
export async function POST(request: NextRequest) {
let connection;
try {
const { username, password } = await request.json();
@@ -38,21 +19,21 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 });
}
const users = await readUsers();
const user = users.find(u => u.username === username);
connection = await pool.getConnection();
if (!user || user.passwordHash !== hashPassword(password)) {
const [rows] = await connection.query<any[]>('SELECT * FROM users WHERE username = ?', [username]);
if (rows.length === 0 || rows[0].password_hash !== hashPassword(password)) {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
}
const newToken = generateToken();
user.token = newToken;
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2));
await connection.query('UPDATE users SET token = ? WHERE id = ?', [newToken, rows[0].id]);
const response = NextResponse.json({ success: true, username: user.username });
const response = NextResponse.json({ success: true, username });
response.cookies.set('auth_token', newToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
@@ -62,5 +43,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Login error:', error);
return NextResponse.json({ error: '登录失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
}

View File

@@ -1,25 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const USERS_FILE = path.join(process.cwd(), 'data/users.json');
interface User {
id: string;
username: string;
token: string;
}
async function readUsers(): Promise<User[]> {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
import pool from '@/lib/db';
export async function GET(request: NextRequest) {
let connection;
try {
const token = request.cookies.get('auth_token')?.value;
@@ -27,17 +10,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ user: null });
}
const users = await readUsers();
const user = users.find(u => u.token === token);
connection = await pool.getConnection();
if (!user) {
const [rows] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (rows.length === 0) {
return NextResponse.json({ user: null });
}
return NextResponse.json({ user: { username: user.username } });
return NextResponse.json({ user: { username: rows[0].username } });
} catch (error) {
console.error('Get user error:', error);
return NextResponse.json({ user: null });
} finally {
if (connection) connection.release();
}
}
@@ -45,4 +31,4 @@ export async function DELETE(request: NextRequest) {
const response = NextResponse.json({ success: true });
response.cookies.delete('auth_token');
return response;
}
}

View File

@@ -1,31 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
const DATA_DIR = path.join(process.cwd(), 'data');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface User {
id: string;
username: string;
passwordHash: string;
token: string;
createdAt: string;
}
async function readUsers(): Promise<User[]> {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
async function writeUsers(users: User[]): Promise<void> {
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2));
}
import pool from '@/lib/db';
function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex');
@@ -36,6 +11,7 @@ function generateToken(): string {
}
export async function POST(request: NextRequest) {
let connection;
try {
const { username, password } = await request.json();
@@ -47,28 +23,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '用户名至少3位密码至少6位' }, { status: 400 });
}
const users = await readUsers();
connection = await pool.getConnection();
if (users.find(u => u.username === username)) {
const [rows] = await connection.query<any[]>('SELECT id FROM users WHERE username = ?', [username]);
if (rows.length > 0) {
return NextResponse.json({ error: '用户名已存在' }, { status: 400 });
}
const token = generateToken();
const newUser: User = {
id: crypto.randomUUID(),
username,
passwordHash: hashPassword(password),
token,
createdAt: new Date().toISOString()
};
users.push(newUser);
await writeUsers(users);
await connection.query(
'INSERT INTO users (id, username, password_hash, token, created_at) VALUES (?, ?, ?, ?, ?)',
[crypto.randomUUID(), username, hashPassword(password), token, new Date()]
);
const response = NextResponse.json({ success: true, username });
response.cookies.set('auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
secure: false,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7,
path: '/'
@@ -78,5 +50,7 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Register error:', error);
return NextResponse.json({ error: '注册失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
}

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
const CRON_SECRET = 'smalltown_review_secret_2024';
export async function POST(request: NextRequest) {
let connection;
try {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
connection = await pool.getConnection();
const [result] = await connection.query(
"UPDATE houses SET status = 'approved', reviewed_at = NOW() WHERE status = 'pending'"
);
const affectedRows = (result as any).affectedRowCount || 0;
connection.release();
return NextResponse.json({ success: true, approved: affectedRows });
} catch (error) {
console.error('Review error:', error);
return NextResponse.json({ error: '审核失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}

View File

@@ -1,17 +1,21 @@
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const DISTRICTS_FILE = path.join(DATA_DIR, 'districts.json');
import pool from '@/lib/db';
export async function GET() {
let connection;
try {
const data = await fs.readFile(DISTRICTS_FILE, 'utf-8');
const districts = JSON.parse(data);
connection = await pool.getConnection();
const [rows] = await connection.query<any[]>('SELECT name FROM districts ORDER BY sort_order, id');
const districts = rows.map((row: any) => row.name);
connection.release();
return NextResponse.json({ districts });
} catch (error) {
console.error('Get districts error:', error);
return NextResponse.json({ districts: [] });
} finally {
if (connection) connection.release();
}
}

View File

@@ -1,69 +1,43 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const HOUSES_FILE = path.join(DATA_DIR, 'houses.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface House {
id: string;
owner: string;
title: string;
description: string;
price: number;
district: string;
address: string;
phone: string;
images: string[];
createdAt: string;
}
interface User {
id: string;
username: string;
token: string;
}
async function readFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch {
return defaultValue;
}
}
async function getUserFromToken(token: string): Promise<User | null> {
const users = await readFile<User[]>(USERS_FILE, []);
return users.find(u => u.token === token) || null;
}
async function readHouses(): Promise<House[]> {
return readFile<House[]>(HOUSES_FILE, []);
}
async function writeHouses(houses: House[]): Promise<void> {
await fs.writeFile(HOUSES_FILE, JSON.stringify(houses, null, 2));
}
import pool from '@/lib/db';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
let connection;
try {
const { id } = await params;
const houses = await readHouses();
const house = houses.find(h => h.id === id);
connection = await pool.getConnection();
if (!house) {
return NextResponse.json({ error: '房屋不存在' }, { status: 404 });
const [rows] = await connection.query<any[]>("SELECT * FROM houses WHERE id = ? AND status = 'approved'", [id]);
if (rows.length === 0) {
connection.release();
return NextResponse.json({ error: '房屋不存在或待审核' }, { status: 404 });
}
const row = rows[0];
const house = {
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
createdAt: row.created_at
};
connection.release();
return NextResponse.json({ house });
} catch (error) {
console.error('Get house error:', error);
return NextResponse.json({ error: '获取房屋信息失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
@@ -71,6 +45,7 @@ export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
let connection;
try {
const token = request.cookies.get('auth_token')?.value;
@@ -78,43 +53,68 @@ export async function PUT(
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const user = await getUserFromToken(token);
if (!user) {
const { id } = await params;
connection = await pool.getConnection();
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
const { id } = await params;
const houses = await readHouses();
const houseIndex = houses.findIndex(h => h.id === id);
if (houseIndex === -1) {
const [houses] = await connection.query<any[]>('SELECT * FROM houses WHERE id = ?', [id]);
if (houses.length === 0) {
connection.release();
return NextResponse.json({ error: '房屋不存在' }, { status: 404 });
}
if (houses[houseIndex].owner !== user.username) {
if (houses[0].owner !== users[0].username) {
connection.release();
return NextResponse.json({ error: '无权修改此房屋' }, { status: 403 });
}
const body = await request.json();
const { title, description, price, district, address, phone, images } = body;
houses[houseIndex] = {
...houses[houseIndex],
title: title || houses[houseIndex].title,
description: description ?? houses[houseIndex].description,
price: price !== undefined ? Number(price) : houses[houseIndex].price,
district: district || houses[houseIndex].district,
address: address || houses[houseIndex].address,
phone: phone || houses[houseIndex].phone,
images: images || houses[houseIndex].images
await connection.query(
'UPDATE houses SET title = ?, description = ?, price = ?, district = ?, address = ?, phone = ?, images = ?, status = ?, reject_reason = NULL WHERE id = ?',
[
title || houses[0].title,
description ?? houses[0].description,
price !== undefined ? Number(price) : houses[0].price,
district || houses[0].district,
address || houses[0].address,
phone || houses[0].phone,
images ? JSON.stringify(images) : houses[0].images,
'pending',
id
]
);
const [updated] = await connection.query<any[]>('SELECT * FROM houses WHERE id = ?', [id]);
const row = updated[0];
const house = {
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
status: row.status,
reject_reason: row.reject_reason,
createdAt: row.created_at
};
await writeHouses(houses);
return NextResponse.json({ success: true, house: houses[houseIndex] });
connection.release();
return NextResponse.json({ success: true, house });
} catch (error) {
console.error('Update house error:', error);
return NextResponse.json({ error: '更新房屋失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
@@ -122,6 +122,7 @@ export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
let connection;
try {
const token = request.cookies.get('auth_token')?.value;
@@ -129,29 +130,34 @@ export async function DELETE(
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const user = await getUserFromToken(token);
if (!user) {
const { id } = await params;
connection = await pool.getConnection();
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
const { id } = await params;
const houses = await readHouses();
const house = houses.find(h => h.id === id);
if (!house) {
const [houses] = await connection.query<any[]>('SELECT * FROM houses WHERE id = ?', [id]);
if (houses.length === 0) {
connection.release();
return NextResponse.json({ error: '房屋不存在' }, { status: 404 });
}
if (house.owner !== user.username) {
if (houses[0].owner !== users[0].username) {
connection.release();
return NextResponse.json({ error: '无权删除此房屋' }, { status: 403 });
}
const newHouses = houses.filter(h => h.id !== id);
await writeHouses(newHouses);
await connection.query('DELETE FROM houses WHERE id = ?', [id]);
connection.release();
return NextResponse.json({ success: true });
} catch (error) {
console.error('Delete house error:', error);
return NextResponse.json({ error: '删除房屋失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
}

View File

@@ -1,84 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
const DATA_DIR = path.join(process.cwd(), 'data');
const HOUSES_FILE = path.join(DATA_DIR, 'houses.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface House {
id: string;
owner: string;
title: string;
description: string;
price: number;
district: string;
address: string;
phone: string;
images: string[];
createdAt: string;
}
interface User {
id: string;
username: string;
token: string;
}
async function readFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch {
return defaultValue;
}
}
async function getUserFromToken(token: string): Promise<User | null> {
const users = await readFile<User[]>(USERS_FILE, []);
return users.find(u => u.token === token) || null;
}
async function readHouses(): Promise<House[]> {
return readFile<House[]>(HOUSES_FILE, []);
}
async function writeHouses(houses: House[]): Promise<void> {
await fs.writeFile(HOUSES_FILE, JSON.stringify(houses, null, 2));
}
import pool from '@/lib/db';
export async function GET(request: NextRequest) {
let connection;
try {
const { searchParams } = new URL(request.url);
const district = searchParams.get('district');
const keyword = searchParams.get('keyword');
let houses = await readHouses();
connection = await pool.getConnection();
let sql = "SELECT * FROM houses WHERE status = 'approved'";
const params: any[] = [];
if (district && district !== '全部') {
houses = houses.filter(h => h.district === district);
sql += ' AND district = ?';
params.push(district);
}
if (keyword) {
const kw = keyword.toLowerCase();
houses = houses.filter(h =>
h.title.toLowerCase().includes(kw) ||
h.address.toLowerCase().includes(kw) ||
h.description.toLowerCase().includes(kw)
);
sql += ' AND (title LIKE ? OR address LIKE ? OR description LIKE ?)';
const kw = `%${keyword}%`;
params.push(kw, kw, kw);
}
houses.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
sql += ' ORDER BY created_at DESC';
const [rows] = await connection.query<any[]>(sql, params);
const houses = rows.map((row: any) => ({
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
createdAt: row.created_at
}));
connection.release();
return NextResponse.json({ houses });
} catch (error) {
console.error('Get houses error:', error);
return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
export async function POST(request: NextRequest) {
let connection;
try {
const token = request.cookies.get('auth_token')?.value;
@@ -86,8 +61,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const user = await getUserFromToken(token);
if (!user) {
connection = await pool.getConnection();
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
@@ -95,12 +74,19 @@ export async function POST(request: NextRequest) {
const { title, description, price, district, address, phone, images } = body;
if (!title || !price || !district || !address || !phone) {
connection.release();
return NextResponse.json({ error: '请填写完整信息' }, { status: 400 });
}
const house: House = {
id: crypto.randomUUID(),
owner: user.username,
const id = crypto.randomUUID();
await connection.query(
'INSERT INTO houses (id, owner, title, description, price, district, address, phone, images, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, users[0].username, title, description || '', Number(price), district, address, phone, JSON.stringify(images || []), 'pending', new Date()]
);
const house = {
id,
owner: users[0].username,
title,
description: description || '',
price: Number(price),
@@ -108,16 +94,16 @@ export async function POST(request: NextRequest) {
address,
phone,
images: images || [],
createdAt: new Date().toISOString()
status: 'pending',
createdAt: new Date()
};
const houses = await readHouses();
houses.push(house);
await writeHouses(houses);
connection.release();
return NextResponse.json({ success: true, house });
} catch (error) {
console.error('Create house error:', error);
return NextResponse.json({ error: '创建房屋失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
}

View File

@@ -1,49 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const HOUSES_FILE = path.join(DATA_DIR, 'houses.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface House {
id: string;
owner: string;
title: string;
description: string;
price: number;
district: string;
address: string;
phone: string;
images: string[];
createdAt: string;
}
interface User {
id: string;
username: string;
token: string;
}
async function readFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch {
return defaultValue;
}
}
async function getUserFromToken(token: string): Promise<User | null> {
const users = await readFile<User[]>(USERS_FILE, []);
return users.find(u => u.token === token) || null;
}
async function readHouses(): Promise<House[]> {
return readFile<House[]>(HOUSES_FILE, []);
}
import pool from '@/lib/db';
export async function GET(request: NextRequest) {
let connection;
try {
const token = request.cookies.get('auth_token')?.value;
@@ -51,19 +10,42 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '请先登录' }, { status: 401 });
}
const user = await getUserFromToken(token);
if (!user) {
connection = await pool.getConnection();
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
}
const houses = await readHouses();
const myHouses = houses
.filter(h => h.owner === user.username)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const [rows] = await connection.query<any[]>(
'SELECT * FROM houses WHERE owner = ? ORDER BY created_at DESC',
[users[0].username]
);
return NextResponse.json({ houses: myHouses });
const houses = rows.map((row: any) => ({
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
status: row.status,
reject_reason: row.reject_reason,
reviewed_at: row.reviewed_at,
createdAt: row.created_at
}));
connection.release();
return NextResponse.json({ houses });
} catch (error) {
console.error('Get my houses error:', error);
return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}
}

View File

@@ -1,8 +1,10 @@
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
export async function POST(request: NextRequest) {
try {
@@ -18,13 +20,53 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '仅支持 JPG、PNG、GIF、WebP 格式' }, { status: 400 });
}
const ext = file.name.split('.').pop() || 'jpg';
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();
await fs.writeFile(filepath, Buffer.from(buffer));
let 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 })
.toBuffer() as any;
quality -= 10;
}
// 如果还是太大,继续缩小尺寸
if (imageBuffer.length > MAX_SIZE && metadata.width && metadata.width > 800) {
imageBuffer = await sharp(imageBuffer)
.resize(800, null, { withoutEnlargement: true })
.jpeg({ quality: 70, progressive: true })
.toBuffer() as any;
}
} else {
// 转换为jpeg并优化
imageBuffer = await sharp(imageBuffer)
.jpeg({ quality: 85, progressive: true })
.toBuffer() as any;
}
await fs.writeFile(filepath, imageBuffer);
// 返回相对路径
const url = `/uploads/${filename}`;
return NextResponse.json({ url });
} catch (error) {

View File

@@ -13,6 +13,9 @@ interface House {
address: string;
phone: string;
images: string[];
status: string;
reject_reason?: string;
reviewed_at?: string;
createdAt: string;
}
@@ -249,8 +252,37 @@ export default function OwnerDashboard() {
<div className="space-y-4">
{houses.map((house) => (
<div key={house.id} className="bg-white rounded-xl overflow-hidden shadow-sm">
<Link href={`/house/${house.id}`}>
<div className="flex">
{house.status === 'approved' ? (
<Link href={`/house/${house.id}`}>
<div className="flex">
{house.images && house.images.length > 0 ? (
<img
src={house.images[0]}
alt={house.title}
className="w-28 h-28 object-cover flex-shrink-0"
/>
) : (
<div className="w-28 h-28 bg-gradient-to-br from-orange-100 to-orange-200 flex items-center justify-center text-3xl flex-shrink-0">
🏠
</div>
)}
<div className="flex-1 p-3 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{house.title}</h3>
<p className="text-gray-500 text-sm truncate">{house.address}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-orange-500 font-bold">
¥{house.price}/
</span>
<span className="text-gray-400 text-xs">{house.district}</span>
</div>
<div className="mt-2">
<span className="inline-block bg-green-100 text-green-700 text-xs px-2 py-1 rounded"></span>
</div>
</div>
</div>
</Link>
) : (
<div className="flex opacity-60">
{house.images && house.images.length > 0 ? (
<img
src={house.images[0]}
@@ -271,9 +303,20 @@ export default function OwnerDashboard() {
</span>
<span className="text-gray-400 text-xs">{house.district}</span>
</div>
<div className="mt-2">
{house.status === 'pending' && (
<span className="inline-block bg-yellow-100 text-yellow-700 text-xs px-2 py-1 rounded"></span>
)}
{house.status === 'rejected' && (
<span className="inline-block bg-red-100 text-red-700 text-xs px-2 py-1 rounded"></span>
)}
</div>
{house.status === 'rejected' && house.reject_reason && (
<p className="text-red-500 text-xs mt-1">{house.reject_reason}</p>
)}
</div>
</div>
</Link>
)}
<div className="border-t border-gray-100 flex">
<button
onClick={() => openForm(house)}

View File

@@ -11,23 +11,45 @@ export default function OwnerPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [user, setUser] = useState<{ username: string } | null>(null);
const [checking, setChecking] = useState(true);
const [ready, setReady] = useState(false);
useEffect(() => {
checkAuth();
}, []);
async function checkAuth() {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (data.user) {
setUser(data.user);
router.push("/owner/dashboard");
let cancelled = false;
async function checkAuth() {
try {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!cancelled && data.user) {
router.replace("/owner/dashboard");
return;
}
} catch (error) {
console.error("检查登录状态失败", error);
}
if (!cancelled) {
setChecking(false);
setReady(true);
}
} catch (error) {
console.error("检查登录状态失败", error);
}
checkAuth();
return () => {
cancelled = true;
};
}, [router]);
if (!ready) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-4xl mb-2 animate-bounce">🏗</div>
<p className="text-gray-500">...</p>
</div>
</div>
);
}
async function handleSubmit(e: React.FormEvent) {

14
lib/db.ts Normal file
View File

@@ -0,0 +1,14 @@
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: '192.168.0.196',
port: 3306,
user: 'smalltown',
password: 'MyPassword1+',
database: 'smalltown',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
export default pool;

199
package-lock.json generated
View File

@@ -8,9 +8,11 @@
"name": "smalltown2",
"version": "0.1.0",
"dependencies": {
"mysql2": "^3.20.0",
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -510,7 +512,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -1465,6 +1466,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
@@ -1549,7 +1614,6 @@
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@@ -2405,6 +2469,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axe-core": {
"version": "4.11.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
@@ -2783,11 +2856,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -3662,6 +3743,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -3926,6 +4016,22 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4248,6 +4354,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4846,6 +4958,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4869,6 +4987,21 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4943,6 +5076,40 @@
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz",
"integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -5636,6 +5803,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -5707,7 +5880,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -5751,7 +5923,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -5867,6 +6038,21 @@
"node": ">=0.10.0"
}
},
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6352,7 +6538,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

View File

@@ -9,9 +9,11 @@
"lint": "eslint"
},
"dependencies": {
"mysql2": "^3.20.0",
"next": "16.2.1",
"react": "19.2.4",
"react-dom": "19.2.4"
"react-dom": "19.2.4",
"sharp": "^0.34.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

6
run_review.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
CRON_SECRET="smalltown_review_secret_2024"
API_URL="http://localhost:3000/api/cron/review"
curl -X POST -H "Authorization: Bearer $CRON_SECRET" "$API_URL"