feat: 系统配置 + 用户签名

- 新增 sys_config 表,存储应用版本号、标语、描述
- 关于小菜弹窗版本号改为从数据库动态获取
- 用户表新增 slogan 字段(个性签名)
- 编辑资料页面支持修改签名
- 管理后台新增系统配置管理页面
- 更新 CLAUDE.md 文档
This commit is contained in:
2026-06-08 10:37:04 +08:00
parent 29614fdb70
commit 2705335002
15 changed files with 469 additions and 21 deletions

View File

@@ -165,6 +165,56 @@ async function runMigrations(conn: mysql.Connection) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// users 表添加 slogan个性签名
const hasSlogan = await columnExists(conn, 'users', 'slogan')
if (!hasSlogan) {
console.log('[DB] Migrating: adding slogan to users')
await conn.query("ALTER TABLE users ADD COLUMN slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名' AFTER avatar_url")
}
// 系统配置表
const hasSysConfig = await tableExists(conn, 'sys_config')
if (!hasSysConfig) {
console.log('[DB] Migrating: creating sys_config table')
await conn.query(`
CREATE TABLE IF NOT EXISTS sys_config (
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
config_value TEXT NOT NULL COMMENT '配置值',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
// 插入默认配置
await conn.query(`
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
('app_version', '1.0.0'),
('app_slogan', '温暖 x 轻松的个人记账小程序'),
('app_description', '让记账从负担变成享受')
`)
}
// 反馈表
const hasFeedbacks = await tableExists(conn, 'feedbacks')
if (!hasFeedbacks) {
console.log('[DB] Migrating: creating feedbacks table')
await conn.query(`
CREATE TABLE IF NOT EXISTS feedbacks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
type ENUM('bug', 'feature', 'ux', 'other') NOT NULL DEFAULT 'other' COMMENT '反馈类型',
content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(100) DEFAULT '' COMMENT '联系方式',
status ENUM('pending', 'processed', 'ignored') DEFAULT 'pending' COMMENT '处理状态',
admin_reply TEXT DEFAULT '' COMMENT '管理员回复',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_status (status),
INDEX idx_created (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
}
export async function initDatabase() {

View File

@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
slogan VARCHAR(100) DEFAULT '记账小能手' COMMENT '个性签名',
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@@ -130,3 +131,9 @@ CREATE TABLE IF NOT EXISTS feedbacks (
INDEX idx_created (created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS sys_config (
config_key VARCHAR(50) PRIMARY KEY COMMENT '配置键',
config_value TEXT NOT NULL COMMENT '配置值',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -15,3 +15,9 @@ INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VAL
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);
-- 系统配置
INSERT IGNORE INTO sys_config (config_key, config_value) VALUES
('app_version', '1.0.0'),
('app_slogan', '温暖 x 轻松的个人记账小程序'),
('app_description', '让记账从负担变成享受');

View File

@@ -19,6 +19,7 @@ import notificationRoutes from './routes/notification'
import filterRoutes from './routes/filter'
import adminRoutes from './routes/admin'
import feedbackRoutes from './routes/feedback'
import configRoutes from './routes/config'
import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback
@@ -151,6 +152,7 @@ app.use('/api/notifications', apiLimiter, notificationRoutes)
app.use('/api/filters', apiLimiter, filterRoutes)
app.use('/api/admin', apiLimiter, adminRoutes)
app.use('/api/feedback', apiLimiter, feedbackRoutes)
app.use('/api/config', apiLimiter, configRoutes)
app.get('/api/health', async (_req, res) => {
try {

View File

@@ -0,0 +1,53 @@
import { Router } from 'express'
import { pool } from '../db/connection'
import { authMiddleware } from '../middleware/auth'
const router = Router()
/** 获取公开配置(无需管理员权限) */
router.get('/', async (_req, res) => {
try {
const [rows] = await pool.execute('SELECT config_key, config_value FROM sys_config')
const config: Record<string, string> = {}
for (const row of rows as any[]) {
config[row.config_key] = row.config_value
}
res.json(config)
} catch (error) {
console.error('Get config error:', error)
res.status(500).json({ error: '获取配置失败' })
}
})
/** 更新配置(管理员权限) */
router.put('/', authMiddleware, async (req, res) => {
try {
const userId = (req as any).userId
const [users] = await pool.execute('SELECT role FROM users WHERE id = ?', [userId])
const user = (users as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ error: '无权限' })
}
const data = req.body
if (!data || typeof data !== 'object') {
return res.status(400).json({ error: '无效的配置数据' })
}
// 批量更新配置
for (const [key, value] of Object.entries(data)) {
await pool.execute(
'INSERT INTO sys_config (config_key, config_value) VALUES (?, ?) ON DUPLICATE KEY UPDATE config_value = ?',
[key, value, value]
)
}
res.json({ success: true })
} catch (error) {
console.error('Update config error:', error)
res.status(500).json({ error: '更新配置失败' })
}
})
export default router

View File

@@ -48,7 +48,7 @@ const upload = multer({
router.get('/me', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, nickname, avatar_url, role, created_at FROM users WHERE id = ?',
'SELECT id, nickname, avatar_url, slogan, role, created_at FROM users WHERE id = ?',
[req.userId]
)
const user = (rows as any[])[0]
@@ -62,10 +62,10 @@ router.get('/me', async (req: AuthRequest, res: Response) => {
}
})
/** 更新用户昵称 */
/** 更新用户昵称/签名 */
router.put('/me', async (req: AuthRequest, res: Response) => {
try {
const { nickname } = req.body
const { nickname, slogan } = req.body
if (nickname !== undefined) {
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
@@ -76,6 +76,15 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
}
}
if (slogan !== undefined) {
if (typeof slogan !== 'string') {
return res.status(400).json({ code: 40001, message: '签名格式错误' })
}
if (slogan.length > 100) {
return res.status(400).json({ code: 40001, message: '签名不能超过100个字符' })
}
}
const updates: string[] = []
const params: any[] = []
@@ -84,6 +93,11 @@ router.put('/me', async (req: AuthRequest, res: Response) => {
params.push(nickname.trim())
}
if (slogan !== undefined) {
updates.push('slogan = ?')
params.push(slogan.trim())
}
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
}