优化验证报名信息对话框车牌号输入布局,使标签和输入框在同一行显示
This commit is contained in:
+3
-14
@@ -1,17 +1,6 @@
|
||||
PORT=3000
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your-db-password
|
||||
DB_NAME=quanyoumi
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
|
||||
# MinIO 配置
|
||||
MINIO_ENDPOINT=localhost # 后端连接 MinIO 的地址
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=jFywUsaCb4zNr2LV7hTP
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=quanyoumi
|
||||
# MINIO_PUBLIC_URL(可选):前端访问文件的公开地址,不填则自动用 MINIO_ENDPOINT 拼接
|
||||
# 生产环境如果用了反向代理(如 Nginx),填写外网地址,例如:https://cdn.yourdomain.com
|
||||
# MINIO_PUBLIC_URL=http://localhost:9000
|
||||
# 高德地图配置
|
||||
AMAP_KEY=your-amap-key
|
||||
AMAP_SECURITY_CODE=your-amap-security-code
|
||||
|
||||
+22
-4
@@ -5,8 +5,21 @@ const { Admin, sequelize } = require('./models');
|
||||
|
||||
async function initAdmin() {
|
||||
try {
|
||||
// 使用 force: false 避免 alter 表时的问题
|
||||
// 如果表不存在则创建,存在则跳过
|
||||
await sequelize.sync({ force: false });
|
||||
|
||||
// 检查是否已存在管理员账号
|
||||
const existingAdmin = await Admin.findOne({ where: { username: 'admin' } });
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log('✓ 管理员账号已存在');
|
||||
console.log(' 用户名: admin');
|
||||
console.log(' 提示: 如需重置密码,请删除数据库文件后重新初始化');
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
||||
await Admin.create({
|
||||
username: 'admin',
|
||||
@@ -14,12 +27,17 @@ async function initAdmin() {
|
||||
name: '管理员'
|
||||
});
|
||||
|
||||
console.log('管理员账号创建成功');
|
||||
console.log('用户名: admin');
|
||||
console.log('密码: admin123');
|
||||
console.log('✓ 管理员账号创建成功');
|
||||
console.log(' 用户名: admin');
|
||||
console.log(' 密码: admin123');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('创建失败:', error.message);
|
||||
console.error('✗ 初始化失败:', error.message);
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
console.error(' 原因: 管理员账号已存在');
|
||||
} else {
|
||||
console.error(' 详细错误:', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { uploadBuffer } = require('../utils/minio');
|
||||
const fs = require('fs');
|
||||
|
||||
// 确保上传目录存在
|
||||
const uploadDir = path.join(__dirname, '../uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 配置本地存储
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = `${Date.now()}-${Math.random().toString(36).substring(7)}${path.extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
// 使用内存存储,文件不落盘,不限制大小
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
storage,
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test(
|
||||
path.extname(file.originalname).toLowerCase()
|
||||
@@ -16,15 +32,14 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
// 将 req.files 或 req.file 上传到 MinIO,返回 url 数组或单个 url
|
||||
const uploadFilesToMinio = async (files) => {
|
||||
// 返回文件访问URL
|
||||
const getFileUrls = (files) => {
|
||||
const list = Array.isArray(files) ? files : [files];
|
||||
return Promise.all(list.map(async (file) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const filename = `${Date.now()}-${uuidv4()}${ext}`;
|
||||
const url = await uploadBuffer(file.buffer, filename, file.mimetype);
|
||||
return { ...file, filename, url };
|
||||
return list.map(file => ({
|
||||
url: `/uploads/${file.filename}`,
|
||||
filename: file.filename,
|
||||
mimetype: file.mimetype
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = { upload, uploadFilesToMinio };
|
||||
module.exports = { upload, getFileUrls };
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 迁移: 初始化数据库结构(确保所有表和字段存在)
|
||||
* 版本: 0000
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 检查并创建所有必需的表和字段...');
|
||||
|
||||
// Activities 表的额外字段
|
||||
const activityFields = [
|
||||
{ name: 'registrationEnabled', sql: 'ALTER TABLE Activities ADD COLUMN registrationEnabled INTEGER DEFAULT 1;' },
|
||||
{ name: 'coverImage', sql: 'ALTER TABLE Activities ADD COLUMN coverImage TEXT;' },
|
||||
{ name: 'price', sql: 'ALTER TABLE Activities ADD COLUMN price DECIMAL(10, 2) DEFAULT 0;' },
|
||||
{ name: 'budget', sql: 'ALTER TABLE Activities ADD COLUMN budget DECIMAL(10, 2) DEFAULT 0;' },
|
||||
{ name: 'locationLat', sql: 'ALTER TABLE Activities ADD COLUMN locationLat DECIMAL(10, 7);' },
|
||||
{ name: 'locationLng', sql: 'ALTER TABLE Activities ADD COLUMN locationLng DECIMAL(10, 7);' }
|
||||
];
|
||||
|
||||
for (const field of activityFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Registrations 表的额外字段
|
||||
const registrationFields = [
|
||||
{ name: 'peopleCount', sql: 'ALTER TABLE Registrations ADD COLUMN peopleCount INTEGER DEFAULT 1;' },
|
||||
{ name: 'note', sql: 'ALTER TABLE Registrations ADD COLUMN note TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of registrationFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Expenses 表的额外字段
|
||||
const expenseFields = [
|
||||
{ name: 'category', sql: 'ALTER TABLE Expenses ADD COLUMN category TEXT;' },
|
||||
{ name: 'receipt', sql: 'ALTER TABLE Expenses ADD COLUMN receipt TEXT;' },
|
||||
{ name: 'note', sql: 'ALTER TABLE Expenses ADD COLUMN note TEXT;' },
|
||||
{ name: 'media', sql: 'ALTER TABLE Expenses ADD COLUMN media TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of expenseFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Photos 表的额外字段
|
||||
const photoFields = [
|
||||
{ name: 'description', sql: 'ALTER TABLE Photos ADD COLUMN description TEXT;' },
|
||||
{ name: 'uploadedBy', sql: 'ALTER TABLE Photos ADD COLUMN uploadedBy TEXT;' },
|
||||
{ name: 'status', sql: 'ALTER TABLE Photos ADD COLUMN status TEXT DEFAULT "approved";' }
|
||||
];
|
||||
|
||||
for (const field of photoFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Sponsors 表的额外字段
|
||||
const sponsorFields = [
|
||||
{ name: 'logo', sql: 'ALTER TABLE Sponsors ADD COLUMN logo TEXT;' },
|
||||
{ name: 'type', sql: 'ALTER TABLE Sponsors ADD COLUMN type TEXT DEFAULT "sponsor";' },
|
||||
{ name: 'link', sql: 'ALTER TABLE Sponsors ADD COLUMN link TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of sponsorFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Admins 表的额外字段
|
||||
const adminFields = [
|
||||
{ name: 'name', sql: 'ALTER TABLE Admins ADD COLUMN name TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of adminFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
console.log(' - 初始化完成');
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 迁移: 添加弹幕功能字段
|
||||
* 版本: 0001
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 danmakuEnabled 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Activities ADD COLUMN danmakuEnabled INTEGER DEFAULT 0;
|
||||
`);
|
||||
|
||||
console.log(' - 添加 danmakuContent 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Activities ADD COLUMN danmakuContent TEXT;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
// SQLite 不支持 DROP COLUMN,需要重建表
|
||||
console.log(' - 回滚不支持,请手动处理');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 迁移: 添加赞助商排序字段
|
||||
* 版本: 0002
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 Sponsors.sortOrder 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Sponsors ADD COLUMN sortOrder INTEGER DEFAULT 0;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 迁移: 添加签到功能字段
|
||||
* 版本: 0003
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 Registrations.checkInStatus 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Registrations ADD COLUMN checkInStatus INTEGER DEFAULT 0;
|
||||
`);
|
||||
|
||||
console.log(' - 添加 Registrations.checkInTime 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Registrations ADD COLUMN checkInTime DATETIME;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 迁移: 添加奖品表
|
||||
* 版本: 0004
|
||||
* 日期: 2025-01-10
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 创建 Prizes 表...');
|
||||
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE IF NOT EXISTS Prizes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK(level IN ('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 删除 Prizes 表...');
|
||||
await manager.executeSql('DROP TABLE IF EXISTS Prizes;');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
# 数据库迁移系统
|
||||
|
||||
## 概述
|
||||
|
||||
本项目使用版本化的数据库迁移系统,支持增量更新和自动同步。
|
||||
|
||||
## 特性
|
||||
|
||||
- ✅ 版本管理:每个迁移有唯一版本号
|
||||
- ✅ 增量执行:只执行未执行的迁移
|
||||
- ✅ 自动运行:服务器启动时自动执行迁移
|
||||
- ✅ 幂等性:重复执行不会出错
|
||||
- ✅ 迁移记录:记录所有已执行的迁移
|
||||
|
||||
## 迁移文件命名规范
|
||||
|
||||
```
|
||||
XXXX-description.js
|
||||
```
|
||||
|
||||
- `XXXX`: 4位数字版本号(如 0001, 0002)
|
||||
- `description`: 迁移描述(使用短横线分隔)
|
||||
|
||||
示例:
|
||||
- `0001-add-danmaku-fields.js`
|
||||
- `0002-add-user-avatar.js`
|
||||
|
||||
## 创建新迁移
|
||||
|
||||
### 1. 创建迁移文件
|
||||
|
||||
在 `backend/migrations/` 目录下创建新文件:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 迁移: 添加用户头像字段
|
||||
* 版本: 0002
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 avatar 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Users ADD COLUMN avatar TEXT;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
// 回滚逻辑(可选)
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 迁移方法
|
||||
|
||||
#### `up(manager)` - 升级
|
||||
执行数据库变更的方法。
|
||||
|
||||
可用的 manager 方法:
|
||||
- `executeSql(sql)`: 执行SQL语句
|
||||
- `db`: 直接访问数据库连接
|
||||
|
||||
#### `down(manager)` - 回滚(可选)
|
||||
回滚数据库变更的方法。
|
||||
|
||||
注意:SQLite 不支持 DROP COLUMN,回滚可能需要重建表。
|
||||
|
||||
## 执行迁移
|
||||
|
||||
### 自动执行(推荐)
|
||||
|
||||
服务器启动时会自动执行所有未执行的迁移:
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
cd backend
|
||||
npm start
|
||||
|
||||
# 生产环境(Docker)
|
||||
docker-compose up -d
|
||||
# 或
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### 手动执行
|
||||
|
||||
#### 在容器内执行:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
migrate.bat
|
||||
|
||||
# Linux/Mac
|
||||
chmod +x migrate.sh
|
||||
./migrate.sh
|
||||
```
|
||||
|
||||
#### 直接在容器中执行:
|
||||
|
||||
```bash
|
||||
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||
```
|
||||
|
||||
## 迁移状态
|
||||
|
||||
迁移执行记录保存在 `migrations` 表中:
|
||||
|
||||
```sql
|
||||
SELECT * FROM migrations;
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```
|
||||
id | version | name | executed_at
|
||||
---+---------+-----------------------+---------------------
|
||||
1 | 1 | add-danmaku-fields | 2025-01-09 10:30:00
|
||||
2 | 2 | add-user-avatar | 2025-01-09 11:00:00
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 迁移失败怎么办?
|
||||
|
||||
A: 检查错误日志,修复问题后重新启动服务器。已执行的迁移不会重复执行。
|
||||
|
||||
### Q: 如何回滚迁移?
|
||||
|
||||
A: 目前不支持自动回滚。如需回滚,请手动修改数据库或创建新的迁移来撤销更改。
|
||||
|
||||
### Q: 可以修改已执行的迁移吗?
|
||||
|
||||
A: 不建议。已执行的迁移不应修改。如需更改,请创建新的迁移。
|
||||
|
||||
### Q: 迁移在什么时候执行?
|
||||
|
||||
A: 服务器启动时自动执行。也可以手动运行 `migrate.bat` 或 `migrate.sh`。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **版本号递增**:新迁移的版本号应大于所有现有迁移
|
||||
2. **描述清晰**:文件名和注释应清楚说明迁移目的
|
||||
3. **测试迁移**:在开发环境测试后再部署到生产环境
|
||||
4. **幂等性**:迁移应该可以安全地重复执行
|
||||
5. **小步迭代**:每个迁移只做一件事,便于追踪和回滚
|
||||
|
||||
## 示例
|
||||
|
||||
### 添加新字段
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Activities ADD COLUMN featured INTEGER DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 创建新表
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE IF NOT EXISTS Comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
userId INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 修改数据
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
await manager.executeSql(`
|
||||
UPDATE Activities SET status = 'completed' WHERE endTime < datetime('now');
|
||||
`);
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,141 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'data', 'database.sqlite');
|
||||
const migrationsDir = __dirname;
|
||||
|
||||
class MigrationManager {
|
||||
constructor() {
|
||||
this.db = new sqlite3.Database(dbPath);
|
||||
}
|
||||
|
||||
// 初始化迁移表
|
||||
async initMigrationTable() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version INTEGER NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 获取已执行的迁移版本
|
||||
async getExecutedMigrations() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all('SELECT version FROM migrations ORDER BY version', (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows.map(r => r.version));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 记录迁移执行
|
||||
async recordMigration(version, name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'INSERT INTO migrations (version, name) VALUES (?, ?)',
|
||||
[version, name],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 执行SQL语句
|
||||
async executeSql(sql) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, (err) => {
|
||||
if (err) {
|
||||
// 忽略字段已存在的错误
|
||||
if (err.message.includes('duplicate column name')) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 加载所有迁移文件
|
||||
loadMigrations() {
|
||||
const files = fs.readdirSync(migrationsDir)
|
||||
.filter(f => f.match(/^\d{4}-.*\.js$/) && f !== 'index.js')
|
||||
.sort();
|
||||
|
||||
return files.map(file => {
|
||||
const version = parseInt(file.split('-')[0]);
|
||||
const name = file.replace(/^\d{4}-/, '').replace('.js', '');
|
||||
const migration = require(path.join(migrationsDir, file));
|
||||
return { version, name, file, migration };
|
||||
});
|
||||
}
|
||||
|
||||
// 执行迁移
|
||||
async runMigrations() {
|
||||
try {
|
||||
console.log('🔄 开始数据库迁移...\n');
|
||||
|
||||
// 初始化迁移表
|
||||
await this.initMigrationTable();
|
||||
|
||||
// 获取已执行的迁移
|
||||
const executed = await this.getExecutedMigrations();
|
||||
console.log(`📊 已执行迁移: ${executed.length} 个`);
|
||||
|
||||
// 加载所有迁移文件
|
||||
const migrations = this.loadMigrations();
|
||||
console.log(`📁 发现迁移文件: ${migrations.length} 个\n`);
|
||||
|
||||
// 执行未执行的迁移
|
||||
let count = 0;
|
||||
for (const { version, name, file, migration } of migrations) {
|
||||
if (!executed.includes(version)) {
|
||||
console.log(`⏳ 执行迁移 [${version}] ${name}...`);
|
||||
|
||||
try {
|
||||
await migration.up(this);
|
||||
await this.recordMigration(version, name);
|
||||
console.log(`✅ 迁移 [${version}] ${name} 完成\n`);
|
||||
count++;
|
||||
} catch (error) {
|
||||
console.error(`❌ 迁移 [${version}] ${name} 失败:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
console.log(`⏭️ 跳过已执行的迁移 [${version}] ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✨ 迁移完成!共执行 ${count} 个新迁移`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 迁移失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
const manager = new MigrationManager();
|
||||
manager.runMigrations()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
}
|
||||
|
||||
module.exports = MigrationManager;
|
||||
@@ -38,6 +38,15 @@ const Activity = sequelize.define('Activity', {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
defaultValue: 0,
|
||||
comment: '活动预算'
|
||||
},
|
||||
danmakuEnabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '弹幕开关'
|
||||
},
|
||||
danmakuContent: {
|
||||
type: DataTypes.TEXT,
|
||||
comment: '弹幕内容JSON'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('./db');
|
||||
|
||||
const Prize = sequelize.define('Prize', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
activityId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
level: {
|
||||
type: DataTypes.ENUM('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼'),
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
image: DataTypes.STRING,
|
||||
quantity: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '排序值,数字越小越靠前'
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = Prize;
|
||||
@@ -20,7 +20,12 @@ const Sponsor = sequelize.define('Sponsor', {
|
||||
type: DataTypes.ENUM('title', 'sponsor'),
|
||||
defaultValue: 'sponsor'
|
||||
},
|
||||
link: DataTypes.STRING
|
||||
link: DataTypes.STRING,
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '排序值,数字越小越靠前'
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = Sponsor;
|
||||
|
||||
+13
-10
@@ -1,14 +1,17 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'quanyoumi',
|
||||
process.env.DB_USER || 'root',
|
||||
process.env.DB_PASSWORD || '',
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
dialect: 'mysql',
|
||||
logging: false
|
||||
}
|
||||
);
|
||||
// 确保data目录存在
|
||||
const dataDir = path.join(__dirname, '../data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dataDir, 'database.sqlite'),
|
||||
logging: false
|
||||
});
|
||||
|
||||
module.exports = sequelize;
|
||||
|
||||
@@ -4,11 +4,15 @@ const Registration = require('./Registration');
|
||||
const Expense = require('./Expense');
|
||||
const Photo = require('./Photo');
|
||||
const Sponsor = require('./Sponsor');
|
||||
const Prize = require('./Prize');
|
||||
const Admin = require('./Admin');
|
||||
|
||||
// 同步数据库
|
||||
sequelize.sync({ alter: true }).then(() => {
|
||||
console.log('数据库同步完成');
|
||||
// 使用 force: false 避免 SQLite alter 表时的约束冲突问题
|
||||
sequelize.sync({ force: false }).then(() => {
|
||||
console.log('✓ 数据库同步完成');
|
||||
}).catch(err => {
|
||||
console.error('✗ 数据库同步失败:', err.message);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
@@ -18,5 +22,6 @@ module.exports = {
|
||||
Expense,
|
||||
Photo,
|
||||
Sponsor,
|
||||
Prize,
|
||||
Admin
|
||||
};
|
||||
|
||||
Generated
+365
-359
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minio": "^8.0.7",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.6.0",
|
||||
"sequelize": "^6.33.0",
|
||||
"uuid": "^9.0.1"
|
||||
"sql.js": "^1.14.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Activity, Photo, Sponsor, Expense, Registration } = require('../models');
|
||||
const { upload, uploadFilesToMinio } = require('../middleware/upload');
|
||||
const { Activity, Photo, Sponsor, Expense, Registration, Prize } = require('../models');
|
||||
const { upload, getFileUrls } = require('../middleware/upload');
|
||||
|
||||
// 获取活动列表
|
||||
router.get('/activities', async (req, res) => {
|
||||
@@ -23,10 +23,17 @@ router.get('/activities/:id', async (req, res) => {
|
||||
const photos = await Photo.findAll({
|
||||
where: { activityId: req.params.id, status: 'approved' }
|
||||
});
|
||||
const sponsors = await Sponsor.findAll({ where: { activityId: req.params.id } });
|
||||
const sponsors = await Sponsor.findAll({
|
||||
where: { activityId: req.params.id },
|
||||
order: [['sortOrder', 'ASC'], ['id', 'ASC']]
|
||||
});
|
||||
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
|
||||
const prizes = await Prize.findAll({
|
||||
where: { activityId: req.params.id },
|
||||
order: [['sortOrder', 'ASC'], ['id', 'ASC']]
|
||||
});
|
||||
|
||||
res.json({ activity, photos, sponsors, expenses });
|
||||
res.json({ activity, photos, sponsors, expenses, prizes });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -46,7 +53,7 @@ router.get('/activities/:id/expenses', async (req, res) => {
|
||||
router.post('/activities/:id/photos', upload.array('photos', 10), async (req, res) => {
|
||||
try {
|
||||
const { uploadedBy } = req.body;
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
const photos = uploaded.map(file => ({
|
||||
activityId: req.params.id,
|
||||
url: file.url,
|
||||
|
||||
+81
-15
@@ -3,9 +3,10 @@ const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const auth = require('../middleware/auth');
|
||||
const { upload, uploadFilesToMinio } = require('../middleware/upload');
|
||||
const { deleteFile } = require('../utils/minio');
|
||||
const { Admin, Activity, Registration, Expense, Photo, Sponsor } = require('../models');
|
||||
const { upload, getFileUrls } = require('../middleware/upload');
|
||||
const { Admin, Activity, Registration, Expense, Photo, Sponsor, Prize } = require('../models');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 管理员登录
|
||||
router.post('/login', async (req, res) => {
|
||||
@@ -100,7 +101,7 @@ router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
|
||||
let media = [];
|
||||
|
||||
if (req.files && req.files.length > 0) {
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
media = uploaded.map(file => ({
|
||||
url: file.url,
|
||||
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
||||
@@ -152,7 +153,7 @@ router.put('/expenses/:id', auth, upload.array('media', 5), async (req, res) =>
|
||||
|
||||
// 处理新上传的媒体
|
||||
if (req.files && req.files.length > 0) {
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
const newMedia = uploaded.map(file => ({
|
||||
url: file.url,
|
||||
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
||||
@@ -184,7 +185,7 @@ router.delete('/expenses/:id', auth, async (req, res) => {
|
||||
router.post('/photos', auth, upload.array('photos', 10), async (req, res) => {
|
||||
try {
|
||||
const { activityId } = req.body;
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
const photos = uploaded.map(file => ({
|
||||
activityId,
|
||||
url: file.url,
|
||||
@@ -231,11 +232,12 @@ router.delete('/photos/:id', auth, async (req, res) => {
|
||||
return res.status(404).json({ error: '照片不存在' });
|
||||
}
|
||||
|
||||
// 从URL提取文件名
|
||||
// 删除本地文件
|
||||
const filename = photo.url.split('/').pop();
|
||||
|
||||
// 从MinIO删除文件
|
||||
await deleteFile(filename);
|
||||
const filePath = path.join(__dirname, '../uploads', filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
// 从数据库删除记录
|
||||
await photo.destroy();
|
||||
@@ -249,13 +251,20 @@ router.delete('/photos/:id', auth, async (req, res) => {
|
||||
// 添加赞助商
|
||||
router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { activityId, name, type, link } = req.body;
|
||||
const { activityId, name, type, link, sortOrder } = req.body;
|
||||
let logo = null;
|
||||
if (req.file) {
|
||||
const [uploaded] = await uploadFilesToMinio(req.file);
|
||||
const [uploaded] = getFileUrls(req.file);
|
||||
logo = uploaded.url;
|
||||
}
|
||||
const sponsor = await Sponsor.create({ activityId, name, logo, type, link });
|
||||
const sponsor = await Sponsor.create({
|
||||
activityId,
|
||||
name,
|
||||
logo,
|
||||
type,
|
||||
link,
|
||||
sortOrder: sortOrder || 0
|
||||
});
|
||||
res.json(sponsor);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
@@ -265,11 +274,15 @@ router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
|
||||
// 更新赞助商
|
||||
router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { name, type, link } = req.body;
|
||||
const { name, type, link, sortOrder } = req.body;
|
||||
const updateData = { name, type, link };
|
||||
|
||||
if (sortOrder !== undefined) {
|
||||
updateData.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
if (req.file) {
|
||||
const [uploaded] = await uploadFilesToMinio(req.file);
|
||||
const [uploaded] = getFileUrls(req.file);
|
||||
updateData.logo = uploaded.url;
|
||||
}
|
||||
|
||||
@@ -291,4 +304,57 @@ router.delete('/sponsors/:id', auth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 添加奖品
|
||||
router.post('/prizes', auth, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { activityId, name, level, description, quantity, sortOrder } = req.body;
|
||||
let image = null;
|
||||
if (req.file) {
|
||||
const [uploaded] = getFileUrls([req.file]);
|
||||
image = uploaded.url;
|
||||
}
|
||||
const prize = await Prize.create({
|
||||
activityId,
|
||||
name,
|
||||
level,
|
||||
description,
|
||||
image,
|
||||
quantity: quantity || 1,
|
||||
sortOrder: sortOrder || 0
|
||||
});
|
||||
res.json(prize);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新奖品
|
||||
router.put('/prizes/:id', auth, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { name, level, description, quantity, sortOrder } = req.body;
|
||||
const updateData = { name, level, description, quantity, sortOrder };
|
||||
|
||||
if (req.file) {
|
||||
const [uploaded] = getFileUrls([req.file]);
|
||||
updateData.image = uploaded.url;
|
||||
}
|
||||
|
||||
await Prize.update(updateData, { where: { id: req.params.id } });
|
||||
const prize = await Prize.findByPk(req.params.id);
|
||||
res.json(prize);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除奖品
|
||||
router.delete('/prizes/:id', auth, async (req, res) => {
|
||||
try {
|
||||
await Prize.destroy({ where: { id: req.params.id } });
|
||||
res.json({ message: '删除成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+23
-6
@@ -9,6 +9,9 @@ const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 静态文件服务 - 提供上传的文件
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// API 路由
|
||||
app.use('/api', require('./routes/activity'));
|
||||
app.use('/api', require('./routes/registration'));
|
||||
@@ -26,11 +29,25 @@ if (process.env.NODE_ENV === 'production') {
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`服务器运行在 http://localhost:${PORT}`);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('运行模式: 生产环境(前后端一体)');
|
||||
} else {
|
||||
console.log('运行模式: 开发环境(仅后端API)');
|
||||
|
||||
// 启动服务器前运行数据库迁移
|
||||
const MigrationManager = require('./migrations/index');
|
||||
const runMigrations = async () => {
|
||||
try {
|
||||
const manager = new MigrationManager();
|
||||
await manager.runMigrations();
|
||||
} catch (error) {
|
||||
console.error('数据库迁移失败,但服务器将继续启动');
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations().then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`服务器运行在 http://localhost:${PORT}`);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('运行模式: 生产环境(前后端一体)');
|
||||
} else {
|
||||
console.log('运行模式: 开发环境(仅后端API)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user