import express from 'express' import { DatabaseSync } from 'node:sqlite' import { mkdirSync, readdirSync, readFileSync, existsSync, createWriteStream, renameSync, unlinkSync } from 'node:fs' import { dirname, join, extname } from 'node:path' import { fileURLToPath } from 'node:url' import { randomBytes } from 'node:crypto' const root = dirname(dirname(fileURLToPath(import.meta.url))) const dataDir = join(root, 'data') const migrationsDir = join(root, 'migrations') mkdirSync(dataDir, { recursive: true }) const dbPath = process.env.DATABASE_PATH || join(dataDir, 'reporter-station.db') const db = new DatabaseSync(dbPath) db.exec(`PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;`) // 运行所有待执行迁移(幂等性由 schema_migrations 表保障) const applied = new Set(db.prepare('SELECT name FROM schema_migrations').all().map(r => r.name)) for (const file of readdirSync(migrationsDir).filter(f => f.endsWith('.sql') && !f.startsWith('_')).sort()) { const name = file.replace('.sql', '') if (applied.has(name)) continue db.exec(readFileSync(join(migrationsDir, file), 'utf-8')) db.prepare('INSERT INTO schema_migrations (name) VALUES (?)').run(name) } // ── Express 基础 ──────────────────────────────────────────────────────────── const app = express() app.use(express.json({ limit: '1mb' })) app.use('/attachments', express.static(join(root, 'public', 'attachments'))) // ── 身份认证(支持 token 会话 + header 模拟回退) ───────────────────────── const identities = { headquarters: { name: '林致远', station: null }, station: { name: '苏明远', station: '北京记者站' }, reporter: { name: '林晓', station: '北京记者站' }, } /** 记录系统操作日志 */ function logSystem(actorRole, actorName, module, action, targetType, targetId, detail) { try { db.prepare(`INSERT INTO system_logs (actor_role, actor_name, module, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?,?)`).run(actorRole, actorName, module, action, targetType || null, String(targetId || ''), detail || null) } catch (e) { console.error('logSystem error:', e.message) } } app.use((req, res, next) => { const token = req.header('x-auth-token') if (token) { const session = db.prepare(` SELECT s.*, p.station FROM login_sessions s LEFT JOIN people p ON p.code = s.user_code WHERE s.token = ? AND s.revoked_at IS NULL AND s.expires_at > datetime('now','localtime') `).get(token) if (session) { req.user = { role: session.role, name: session.user_name, station: session.station || identities[session.role]?.station || null } req.authToken = token return next() } return res.status(401).json({ message: '会话已过期,请重新登录' }) } // 回退:header 模拟(演示用) const role = req.header('x-user-role') || 'headquarters' req.user = identities[role] ? { role, ...identities[role] } : { role: 'headquarters', ...identities.headquarters } next() }) // ── 通用 SQL 片段 ─────────────────────────────────────────────────────────── const recordSelect = ` SELECT id,title,type,reporter,station,occurred_date AS date,platform,status,score, description,review_note AS reviewNote,attachments, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM work_records WHERE deleted_at IS NULL` // ── 辅助函数 ──────────────────────────────────────────────────────────────── function canAccessRecord(user, record) { if (user.role === 'headquarters') return true if (user.role === 'station') return record.station === user.station return record.reporter === user.name } // ═══════════════════════════════════════════════════════════════════════════ // 公共路由 // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/health', (_req, res) => res.json({ status: 'ok', database: 'sqlite' })) // ═══════════════════════════════════════════════════════════════════════════ // 认证 API // ═══════════════════════════════════════════════════════════════════════════ app.post('/api/auth/login', (req, res) => { const { code, password } = req.body if (!code?.trim()) return res.status(400).json({ message: '请输入工号' }) // 查找人员(支持用工号或姓名登录) const person = db.prepare(` SELECT id, code, name, station, title, status FROM people WHERE (code = ? OR name = ?) AND deleted_at IS NULL `).get(code.trim(), code.trim()) if (!person) return res.status(404).json({ message: '用户不存在' }) if (person.status === 'inactive') return res.status(403).json({ message: '账号已停用,请联系管理员' }) // 根据职务推断角色 let role = 'reporter' if (person.station === '总部' || person.title === '总部管理员') role = 'headquarters' else if (person.title?.includes('负责人') || person.title?.includes('站长')) role = 'station' // 生成会话 token const token = randomBytes(32).toString('hex') const expiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19) db.prepare(`INSERT INTO login_sessions (token, user_code, user_name, role, station, expires_at) VALUES (?,?,?,?,?,?)`).run(token, person.code, person.name, role, person.station, expiresAt) logSystem(role, person.name, 'auth', 'login', 'user', person.id, `用户登录,角色: ${role}`) res.json({ token, role, name: person.name, station: person.station, code: person.code }) }) app.post('/api/auth/logout', (req, res) => { if (req.authToken) { db.prepare(`UPDATE login_sessions SET revoked_at = datetime('now','localtime') WHERE token = ?`).run(req.authToken) logSystem(req.user.role, req.user.name, 'auth', 'logout', 'user', null, '用户退出') } res.json({ message: '已退出登录' }) }) app.get('/api/auth/check', (req, res) => { if (!req.authToken) return res.json({ authenticated: false, role: req.user.role, name: req.user.name, demo: true }) res.json({ authenticated: true, role: req.user.role, name: req.user.name, station: req.user.station }) }) // 系统操作日志查询 app.get('/api/system-logs', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可查看系统日志' }) const { module, action, actor, page = '1', pageSize = '50' } = req.query let sql = `SELECT id, actor_role AS actorRole, actor_name AS actorName, module, action, target_type AS targetType, target_id AS targetId, detail, datetime(created_at,'localtime') AS createdAt FROM system_logs WHERE 1=1` const params = [] if (module) { sql += ' AND module = ?'; params.push(module) } if (action) { sql += ' AND action = ?'; params.push(action) } if (actor) { sql += ' AND actor_name LIKE ?'; params.push(`%${actor}%`) } sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?' const limit = Number(pageSize) const offset = (Number(page) - 1) * limit params.push(limit, offset) res.json(db.prepare(sql).all(...params)) }) // ═══════════════════════════════════════════════════════════════════════════ // 工作记录(已有) // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/records', (req, res) => { let sql = recordSelect const params = [] if (req.user.role === 'station') { sql += ' AND station = ?'; params.push(req.user.station) } if (req.user.role === 'reporter') { sql += ' AND reporter = ?'; params.push(req.user.name) } if (req.query.status) { sql += ' AND status = ?'; params.push(req.query.status) } if (req.query.type) { sql += ' AND type = ?'; params.push(req.query.type) } if (req.query.startDate) { sql += ' AND occurred_date >= ?'; params.push(req.query.startDate) } if (req.query.endDate) { sql += ' AND occurred_date <= ?'; params.push(req.query.endDate) } if (req.query.keyword) { sql += ' AND title LIKE ?'; params.push(`%${req.query.keyword}%`) } sql += ' ORDER BY updated_at DESC' res.json(db.prepare(sql).all(...params)) }) app.get('/api/records/:id/audit', (req, res) => { const record = db.prepare(`${recordSelect} AND id = ?`).get(req.params.id) if (!record || !canAccessRecord(req.user, record)) return res.status(404).json({ message: '记录不存在' }) const logs = db.prepare(` SELECT actor_role AS actorRole, actor_name AS actorName, action, from_status AS fromStatus, to_status AS toStatus, score, note, created_at AS createdAt FROM audit_logs WHERE record_id = ? ORDER BY id`).all(req.params.id) res.json(logs) }) app.post('/api/records', (req, res) => { const { title, type, date, platform, description = '', attachments, isDraft } = req.body if (!title?.trim() || !type || !date || !platform?.trim()) return res.status(400).json({ message: '请完整填写必填项' }) if (req.user.role === 'headquarters') return res.status(403).json({ message: '总部管理员不能代替记者填报' }) const reporter = req.user.role === 'reporter' ? req.user.name : '林晓' const station = req.user.station const id = `WK-${date.replaceAll('-', '')}-${String(Date.now()).slice(-4)}` const status = isDraft ? 'draft' : 'station_review' db.prepare(` INSERT INTO work_records (id,title,type,reporter,station,occurred_date,platform,status,description,attachments) VALUES (?,?,?,?,?,?,?,?,?,?)`).run(id, title.trim(), type, reporter, station, date, platform.trim(), status, description, attachments ? JSON.stringify(attachments) : null) db.prepare(` INSERT INTO audit_logs (record_id,actor_role,actor_name,action,to_status,note) VALUES (?,?,?,?,?,?)`).run(id, req.user.role, req.user.name, isDraft ? 'save_draft' : 'submit', status, isDraft ? '保存草稿' : '提交工作记录') logSystem(req.user.role, req.user.name, 'record', isDraft ? 'save_draft' : 'create', 'work_record', id, `标题: ${title}`) res.status(201).json(db.prepare(`${recordSelect} AND id = ?`).get(id)) }) // 保存草稿 app.patch('/api/records/:id/draft', (req, res) => { const record = db.prepare(`${recordSelect} AND id = ?`).get(req.params.id) if (!record) return res.status(404).json({ message: '记录不存在' }) if (record.status !== 'draft' && record.status !== 'returned') return res.status(409).json({ message: '仅草稿或退回状态可编辑' }) if (record.reporter !== req.user.name && req.user.role !== 'headquarters') return res.status(403).json({ message: '仅填报人可编辑' }) const { title, type, date, platform, description, attachments } = req.body const updates = [] const params = [] if (title !== undefined) { updates.push('title = ?'); params.push(title.trim()) } if (type !== undefined) { updates.push('type = ?'); params.push(type) } if (date !== undefined) { updates.push('occurred_date = ?'); params.push(date) } if (platform !== undefined) { updates.push('platform = ?'); params.push(platform.trim()) } if (description !== undefined) { updates.push('description = ?'); params.push(description) } if (attachments !== undefined) { updates.push('attachments = ?'); params.push(attachments ? JSON.stringify(attachments) : null) } if (updates.length === 0) return res.status(400).json({ message: '无有效更新字段' }) updates.push('updated_at = CURRENT_TIMESTAMP') params.push(req.params.id) db.prepare(`UPDATE work_records SET ${updates.join(', ')} WHERE id = ?`).run(...params) res.json(db.prepare(`${recordSelect} AND id = ?`).get(req.params.id)) }) // 提交草稿为审核 app.post('/api/records/:id/submit', (req, res) => { const record = db.prepare(`${recordSelect} AND id = ?`).get(req.params.id) if (!record) return res.status(404).json({ message: '记录不存在' }) if (record.status !== 'draft' && record.status !== 'returned') return res.status(409).json({ message: '仅草稿或退回状态可提交' }) db.prepare(`UPDATE work_records SET status = 'station_review', updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(req.params.id) db.prepare(`INSERT INTO audit_logs (record_id,actor_role,actor_name,action,from_status,to_status,note) VALUES (?,?,?,?,?,?,?)`).run(record.id, req.user.role, req.user.name, 'submit', record.status, 'station_review', '提交审核') res.json(db.prepare(`${recordSelect} AND id = ?`).get(record.id)) }) // 工作记录版本历史 app.get('/api/records/:id/versions', (req, res) => { const record = db.prepare(`${recordSelect} AND id = ?`).get(req.params.id) if (!record || !canAccessRecord(req.user, record)) return res.status(404).json({ message: '记录不存在' }) const versions = db.prepare(`SELECT id, version_no AS versionNo, title, type, platform, description, attachments, edited_by AS editedBy, datetime(edited_at,'localtime') AS editedAt FROM record_versions WHERE record_id = ? ORDER BY version_no DESC`).all(req.params.id) res.json(versions) }) // 附件上传 app.post('/api/records/:id/attachments', (req, res) => { const record = db.prepare(`${recordSelect} AND id = ?`).get(req.params.id) if (!record) return res.status(404).json({ message: '记录不存在' }) if (!canAccessRecord(req.user, record)) return res.status(403).json({ message: '无权操作' }) const { files } = req.body if (!files?.length) return res.status(400).json({ message: '请提供文件信息' }) const existing = record.attachments ? JSON.parse(record.attachments) : [] const updated = [...existing, ...files] db.prepare(`UPDATE work_records SET attachments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(JSON.stringify(updated), req.params.id) logSystem(req.user.role, req.user.name, 'record', 'upload_attachment', 'work_record', req.params.id, `上传 ${files.length} 个附件`) res.json(db.prepare(`${recordSelect} AND id = ?`).get(req.params.id)) }) // 删除附件 app.delete('/api/records/:id/attachments/:index', (req, res) => { const record = db.prepare(`${recordSelect} AND id = ?`).get(req.params.id) if (!record) return res.status(404).json({ message: '记录不存在' }) if (!canAccessRecord(req.user, record)) return res.status(403).json({ message: '无权操作' }) const attachments = record.attachments ? JSON.parse(record.attachments) : [] const idx = Number(req.params.index) if (idx < 0 || idx >= attachments.length) return res.status(400).json({ message: '附件索引无效' }) attachments.splice(idx, 1) db.prepare(`UPDATE work_records SET attachments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(attachments.length ? JSON.stringify(attachments) : null, req.params.id) res.json(db.prepare(`${recordSelect} AND id = ?`).get(req.params.id)) }) app.patch('/api/records/:id/review', (req, res) => { const record = db.prepare(`${recordSelect} AND id = ?`).get(req.params.id) if (!record || !canAccessRecord(req.user, record)) return res.status(404).json({ message: '记录不存在' }) const { decision, score, note = '' } = req.body if (!['pass', 'return'].includes(decision)) return res.status(400).json({ message: '无效的审核决定' }) if (decision === 'return' && !note.trim()) return res.status(400).json({ message: '退回时必须填写原因' }) const expected = req.user.role === 'station' ? 'station_review' : req.user.role === 'headquarters' ? 'headquarters_review' : null if (!expected || record.status !== expected) return res.status(409).json({ message: '无权处理当前状态' }) const nextStatus = decision === 'return' ? 'returned' : req.user.role === 'station' ? 'headquarters_review' : 'archived' const finalScore = Number.isFinite(Number(score)) ? Number(score) : record.score db.exec('BEGIN') try { db.prepare(` UPDATE work_records SET status = ?, score = ?, review_note = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(nextStatus, finalScore, note.trim() || '材料完整,审核通过。', record.id) db.prepare(` INSERT INTO audit_logs (record_id,actor_role,actor_name,action,from_status,to_status,score,note) VALUES (?,?,?,?,?,?,?,?)`) .run(record.id, req.user.role, req.user.name, decision, record.status, nextStatus, finalScore, note) db.exec('COMMIT') } catch (e) { db.exec('ROLLBACK') res.status(500).json({ message: '服务端处理失败' }) } res.json(db.prepare(`${recordSelect} AND id = ?`).get(record.id)) }) // ═══════════════════════════════════════════════════════════════════════════ // TASK-BE-001:人员 CRUD API // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/people', (req, res) => { let sql = `SELECT id,code,name,station,title,phone, joined_at AS joinedAt,status, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM people WHERE deleted_at IS NULL` const params = [] if (req.user.role === 'station') { sql += ' AND station = ?'; params.push(req.user.station) } if (req.query.station) { sql += ' AND station = ?'; params.push(req.query.station) } if (req.query.status) { sql += ' AND status = ?'; params.push(req.query.status) } if (req.query.name) { sql += ' AND name LIKE ?'; params.push(`%${req.query.name}%`) } sql += ' ORDER BY station, name' res.json(db.prepare(sql).all(...params)) }) app.get('/api/people/:id', (req, res) => { const person = db.prepare(`SELECT id,code,name,station,title,phone, joined_at AS joinedAt,status, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM people WHERE id = ? AND deleted_at IS NULL`).get(req.params.id) if (!person) return res.status(404).json({ message: '人员不存在' }) res.json(person) }) app.post('/api/people', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可新增人员' }) const { name, station, title, phone, joinedAt } = req.body if (!name?.trim() || !station?.trim()) return res.status(400).json({ message: '姓名和所属站点为必填项' }) const code = `PERSON_${Date.now()}` try { db.prepare(` INSERT INTO people (code,name,station,title,phone,joined_at) VALUES (?,?,?,?,?,?)`).run(code, name.trim(), station.trim(), title?.trim() || null, phone?.trim() || null, joinedAt || null) const person = db.prepare(`SELECT id,code,name,station,title,phone, joined_at AS joinedAt,status,datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM people WHERE code = ?`).get(code) res.status(201).json(person) } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ message: '人员编码已存在' }) throw e } }) app.patch('/api/people/:id', (req, res) => { const person = db.prepare('SELECT * FROM people WHERE id = ?').get(req.params.id) if (!person) return res.status(404).json({ message: '人员不存在' }) if (req.user.role === 'station' && person.station !== req.user.station) return res.status(403).json({ message: '无权编辑其他站点人员' }) const { title, phone, status } = req.body const updates = [] const params = [] if (title !== undefined) { updates.push('title = ?'); params.push(title.trim()) } if (phone !== undefined) { updates.push('phone = ?'); params.push(phone?.trim() || null) } if (status !== undefined) { if (!['active', 'inactive'].includes(status)) return res.status(400).json({ message: 'status 只能是 active 或 inactive' }) updates.push('status = ?'); params.push(status) } if (updates.length === 0) return res.status(400).json({ message: '无有效更新字段' }) updates.push('updated_at = CURRENT_TIMESTAMP') params.push(req.params.id) db.prepare(`UPDATE people SET ${updates.join(', ')} WHERE id = ?`).run(...params) const updated = db.prepare(`SELECT id,code,name,station,title,phone, joined_at AS joinedAt,status,datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM people WHERE id = ?`).get(req.params.id) res.json(updated) }) app.delete('/api/people/:id', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可删除人员' }) const person = db.prepare('SELECT * FROM people WHERE id = ? AND deleted_at IS NULL').get(req.params.id) if (!person) return res.status(404).json({ message: '人员不存在' }) db.prepare('UPDATE people SET deleted_at = datetime(\'now\',\'localtime\') WHERE id = ?').run(req.params.id) logSystem(req.user.role, req.user.name, 'people', 'delete', 'person', req.params.id, `删除人员: ${person.name}`) res.json({ message: '删除成功' }) }) // ═══════════════════════════════════════════════════════════════════════════ // TASK-BE-002:记者站 CRUD API // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/stations', (req, res) => { let sql = `SELECT id,code,name,region,address,leader,phone,status, established_at AS establishedAt, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM stations WHERE deleted_at IS NULL` const params = [] if (req.query.status) { sql += ' AND status = ?'; params.push(req.query.status) } if (req.query.region) { sql += ' AND region = ?'; params.push(req.query.region) } sql += ' ORDER BY region, name' res.json(db.prepare(sql).all(...params)) }) app.get('/api/stations/:id', (req, res) => { const station = db.prepare(`SELECT id,code,name,region,address,leader,phone,status, established_at AS establishedAt, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM stations WHERE id = ?`).get(req.params.id) if (!station) return res.status(404).json({ message: '记者站不存在' }) res.json(station) }) app.post('/api/stations', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可新增记者站' }) const { name, code, region, address, leader, phone, establishedAt } = req.body if (!name?.trim() || !code?.trim()) return res.status(400).json({ message: '站点名称和编码为必填项' }) try { db.prepare(` INSERT INTO stations (code,name,region,address,leader,phone,established_at) VALUES (?,?,?,?,?,?,?)`) .run(code.trim(), name.trim(), region?.trim() || null, address?.trim() || null, leader?.trim() || null, phone?.trim() || null, establishedAt || null) const station = db.prepare(`SELECT id,code,name,region,address,leader,phone,status, established_at AS establishedAt,datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM stations WHERE code = ?`).get(code.trim()) res.status(201).json(station) } catch (e) { if (e.message.includes('UNIQUE')) return res.status(409).json({ message: '站点编码已存在' }) throw e } }) app.patch('/api/stations/:id', (req, res) => { const station = db.prepare('SELECT * FROM stations WHERE id = ?').get(req.params.id) if (!station) return res.status(404).json({ message: '记者站不存在' }) if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可编辑记者站' }) const { name, region, address, leader, phone, status, establishedAt } = req.body const updates = [] const params = [] if (name !== undefined) { updates.push('name = ?'); params.push(name.trim()) } if (region !== undefined) { updates.push('region = ?'); params.push(region?.trim() || null) } if (address !== undefined) { updates.push('address = ?'); params.push(address?.trim() || null) } if (leader !== undefined) { updates.push('leader = ?'); params.push(leader?.trim() || null) } if (phone !== undefined) { updates.push('phone = ?'); params.push(phone?.trim() || null) } if (status !== undefined) { if (!['active', 'inactive'].includes(status)) return res.status(400).json({ message: 'status 只能是 active 或 inactive' }) updates.push('status = ?'); params.push(status) } if (establishedAt !== undefined) { updates.push('established_at = ?'); params.push(establishedAt || null) } if (updates.length === 0) return res.status(400).json({ message: '无有效更新字段' }) updates.push('updated_at = CURRENT_TIMESTAMP') params.push(req.params.id) db.prepare(`UPDATE stations SET ${updates.join(', ')} WHERE id = ?`).run(...params) const updated = db.prepare(`SELECT id,code,name,region,address,leader,phone,status, established_at AS establishedAt,datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM stations WHERE id = ?`).get(req.params.id) res.json(updated) }) app.delete('/api/stations/:id', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可删除记者站' }) const station = db.prepare('SELECT * FROM stations WHERE id = ? AND deleted_at IS NULL').get(req.params.id) if (!station) return res.status(404).json({ message: '记者站不存在' }) const used = db.prepare('SELECT COUNT(*) AS count FROM people WHERE station = ? AND deleted_at IS NULL').get(station.name).count if (used > 0) return res.status(409).json({ message: `该站点下有 ${used} 名人员,请先移除后再删除` }) db.prepare('UPDATE stations SET deleted_at = datetime(\'now\',\'localtime\') WHERE id = ?').run(req.params.id) logSystem(req.user.role, req.user.name, 'stations', 'delete', 'station', req.params.id, `删除站点: ${station.name}`) res.json({ message: '删除成功' }) }) // ═══════════════════════════════════════════════════════════════════════════ // TASK-BE-003:通知公告 API // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/notices', (req, res) => { let sql = `SELECT id,code,title,content,priority,scope,stations,roles,attachment, published_by AS publishedBy, datetime(published_at,'localtime') AS publishedAt, datetime(created_at,'localtime') AS createdAt FROM notices WHERE 1=1` const params = [] if (req.query.priority) { sql += ' AND priority = ?'; params.push(req.query.priority) } if (req.query.scope) { sql += ' AND scope = ?'; params.push(req.query.scope) } sql += ' ORDER BY published_at DESC' res.json(db.prepare(sql).all(...params)) }) app.get('/api/notices/:id', (req, res) => { const notice = db.prepare(`SELECT id,code,title,content,priority,scope,stations,roles,attachment, published_by AS publishedBy, datetime(published_at,'localtime') AS publishedAt, datetime(created_at,'localtime') AS createdAt FROM notices WHERE id = ?`).get(req.params.id) if (!notice) return res.status(404).json({ message: '通知不存在' }) res.json(notice) }) app.get('/api/notices/:id/receipts', (req, res) => { const notice = db.prepare('SELECT * FROM notices WHERE id = ?').get(req.params.id) if (!notice) return res.status(404).json({ message: '通知不存在' }) const receipts = db.prepare(` SELECT id,notice_id AS noticeId,receiver_name AS receiverName,receiver_role AS receiverRole, read,confirmed,datetime(read_at,'localtime') AS readAt, datetime(confirmed_at,'localtime') AS confirmedAt, datetime(created_at,'localtime') AS createdAt FROM notice_receipts WHERE notice_id = ?`).all(req.params.id) res.json(receipts) }) app.post('/api/notices', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可发布通知' }) const { title, content, priority, scope, stations, roles } = req.body if (!title?.trim()) return res.status(400).json({ message: '通知标题为必填项' }) if (!['normal', 'high', 'urgent'].includes(priority || 'normal')) return res.status(400).json({ message: 'priority 只能是 normal / high / urgent' }) if (!['all', 'station', 'role'].includes(scope || 'all')) return res.status(400).json({ message: 'scope 只能是 all / station / role' }) const code = `NOTICE_${Date.now()}` db.prepare(` INSERT INTO notices (code,title,content,priority,scope,stations,roles,published_by) VALUES (?,?,?,?,?,?,?,?)`) .run(code, title.trim(), content?.trim() || null, priority || 'normal', scope || 'all', stations ? JSON.stringify(stations) : null, roles ? JSON.stringify(roles) : null, req.user.name) // 写入回执记录 const noticeId = db.prepare('SELECT last_insert_rowid() AS id').get().id const receivers = buildReceipts(req.user, { scope: scope || 'all', stations, roles }) if (receivers.length > 0) { const insertReceipt = db.prepare(` INSERT INTO notice_receipts (notice_id,receiver_name,receiver_role) VALUES (?,?,?)`) for (const r of receivers) insertReceipt.run(noticeId, r.name, r.role) } const notice = db.prepare(`SELECT id,code,title,content,priority,scope,stations,roles,attachment, published_by AS publishedBy, datetime(published_at,'localtime') AS publishedAt, datetime(created_at,'localtime') AS createdAt FROM notices WHERE id = ?`).get(noticeId) res.status(201).json(notice) }) app.patch('/api/notices/:id', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可编辑通知' }) const notice = db.prepare('SELECT * FROM notices WHERE id = ?').get(req.params.id) if (!notice) return res.status(404).json({ message: '通知不存在' }) const { title, content, priority } = req.body const updates = [] const params = [] if (title !== undefined) { updates.push('title = ?'); params.push(title.trim()) } if (content !== undefined) { updates.push('content = ?'); params.push(content?.trim() || null) } if (priority !== undefined) { if (!['normal', 'high', 'urgent'].includes(priority)) return res.status(400).json({ message: 'priority 无效' }) updates.push('priority = ?'); params.push(priority) } if (updates.length === 0) return res.status(400).json({ message: '无有效更新字段' }) updates.push('updated_at = CURRENT_TIMESTAMP') params.push(req.params.id) db.prepare(`UPDATE notices SET ${updates.join(', ')} WHERE id = ?`).run(...params) const updated = db.prepare(`SELECT id,code,title,content,priority,scope,stations,roles,attachment, published_by AS publishedBy, datetime(published_at,'localtime') AS publishedAt, datetime(created_at,'localtime') AS createdAt FROM notices WHERE id = ?`).get(req.params.id) res.json(updated) }) app.post('/api/notices/:id/read', (req, res) => { const notice = db.prepare('SELECT * FROM notices WHERE id = ?').get(req.params.id) if (!notice) return res.status(404).json({ message: '通知不存在' }) const receipt = db.prepare(` SELECT * FROM notice_receipts WHERE notice_id = ? AND receiver_name = ? AND receiver_role = ?`) .get(req.params.id, req.user.name, req.user.role) if (!receipt) return res.status(404).json({ message: '无对应回执记录' }) db.prepare(`UPDATE notice_receipts SET read = 1, read_at = CURRENT_TIMESTAMP WHERE id = ?`).run(receipt.id) res.json({ message: '已标记为已读' }) }) app.post('/api/notices/:id/confirm', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可确认回执' }) const notice = db.prepare('SELECT * FROM notices WHERE id = ?').get(req.params.id) if (!notice) return res.status(404).json({ message: '通知不存在' }) const { receiverName, receiverRole } = req.body if (!receiverName || !receiverRole) return res.status(400).json({ message: 'receiverName 和 receiverRole 为必填项' }) const receipt = db.prepare(` SELECT * FROM notice_receipts WHERE notice_id = ? AND receiver_name = ? AND receiver_role = ?`) .get(req.params.id, receiverName, receiverRole) if (!receipt) return res.status(404).json({ message: '回执记录不存在' }) db.prepare(`UPDATE notice_receipts SET confirmed = 1, confirmed_at = CURRENT_TIMESTAMP WHERE id = ?`).run(receipt.id) res.json({ message: '回执已确认' }) }) /** 根据通知范围构建应接收回执的人员列表 */ function buildReceipts(user, { scope, stations, roles }) { const receivers = [] if (scope === 'all') { const people = db.prepare('SELECT name,station FROM people WHERE status = ?').all('active') for (const p of people) receivers.push({ name: p.name, role: 'reporter' }) } else if (scope === 'station') { const stationList = stations || [user.station] for (const s of stationList) { const people = db.prepare('SELECT name FROM people WHERE station = ? AND status = ?').all(s, 'active') for (const p of people) receivers.push({ name: p.name, role: 'reporter' }) } } else if (scope === 'role') { const roleList = roles || ['reporter', 'station'] for (const r of roleList) { const people = db.prepare('SELECT name FROM people WHERE status = ?').all('active') for (const p of people) receivers.push({ name: p.name, role: r }) } } return receivers } // ═══════════════════════════════════════════════════════════════════════════ // TASK-BE-004:数据统计 API // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/stats/overview', (req, res) => { const isHq = req.user.role === 'headquarters' const stationFilter = isHq ? '' : ' AND station = ?' const stationParams = isHq ? [] : [req.user.station] const total = db.prepare(`SELECT COUNT(*) AS count FROM work_records WHERE deleted_at IS NULL${stationFilter}`).get(...stationParams).count const draft = db.prepare(`SELECT COUNT(*) AS count FROM work_records WHERE deleted_at IS NULL${stationFilter} AND status = 'draft'`).get(...stationParams).count const reviewing = db.prepare(`SELECT COUNT(*) AS count FROM work_records WHERE deleted_at IS NULL${stationFilter} AND status IN ('station_review','headquarters_review')`).get(...stationParams).count const archived = db.prepare(`SELECT COUNT(*) AS count FROM work_records WHERE deleted_at IS NULL${stationFilter} AND status = 'archived'`).get(...stationParams).count const returned = db.prepare(`SELECT COUNT(*) AS count FROM work_records WHERE deleted_at IS NULL${stationFilter} AND status = 'returned'`).get(...stationParams).count const avgScore = db.prepare(`SELECT ROUND(AVG(score),1) AS avg FROM work_records WHERE deleted_at IS NULL${stationFilter} AND score IS NOT NULL`).get(...stationParams).avg // 按月统计(最近 6 个月) const monthlyRows = db.prepare(`SELECT strftime('%Y-%m', occurred_date) AS month, COUNT(*) AS count, ROUND(AVG(score),1) AS avgScore FROM work_records WHERE deleted_at IS NULL${stationFilter} AND occurred_date >= date('now','-6 months') GROUP BY strftime('%Y-%m', occurred_date) ORDER BY month DESC`).all(...stationParams) res.json({ total, draft, reviewing, archived, returned, avgScore, monthly: monthlyRows }) }) app.get('/api/stats/records', (req, res) => { const groupBy = req.query.groupBy || 'station' const isHq = req.user.role === 'headquarters' const stationCon = isHq ? '' : ' AND station = ?' const stationParams = isHq ? [] : [req.user.station] let rows if (groupBy === 'station') { rows = db.prepare(`SELECT station AS name, COUNT(*) AS total, SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) AS archived, SUM(CASE WHEN status = 'returned' THEN 1 ELSE 0 END) AS returned, ROUND(AVG(CASE WHEN score IS NOT NULL THEN score END),1) AS avgScore FROM work_records WHERE deleted_at IS NULL${stationCon} GROUP BY station ORDER BY total DESC`).all(...stationParams) } else if (groupBy === 'reporter') { const whereCon = isHq ? '' : ' AND reporter = ?' const whereParams = isHq ? [] : [req.user.name] rows = db.prepare(`SELECT reporter AS name, station, COUNT(*) AS total, SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) AS archived, SUM(CASE WHEN status = 'returned' THEN 1 ELSE 0 END) AS returned, ROUND(AVG(CASE WHEN score IS NOT NULL THEN score END),1) AS avgScore FROM work_records WHERE deleted_at IS NULL${whereCon} GROUP BY reporter, station ORDER BY total DESC`).all(...whereParams) } else if (groupBy === 'type') { rows = db.prepare(`SELECT type AS name, COUNT(*) AS total, SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) AS archived, ROUND(AVG(CASE WHEN score IS NOT NULL THEN score END),1) AS avgScore FROM work_records WHERE deleted_at IS NULL${stationCon} GROUP BY type ORDER BY total DESC`).all(...stationParams) } else { return res.status(400).json({ message: 'groupBy 必须是 station / reporter / type' }) } res.json(rows) }) app.get('/api/stats/scores', (req, res) => { const isHq = req.user.role === 'headquarters' const whereCon = isHq ? '' : ' AND station = ?' const whereParams = isHq ? [] : [req.user.station] const rows = db.prepare(`SELECT reporter AS name, station, COUNT(*) AS submitted, SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) AS archived, SUM(CASE WHEN status = 'returned' THEN 1 ELSE 0 END) AS returned, ROUND(SUM(score),1) AS totalScore, ROUND(AVG(score),1) AS avgScore, MAX(score) AS maxScore, MIN(score) AS minScore FROM work_records WHERE deleted_at IS NULL${whereCon} AND score IS NOT NULL GROUP BY reporter ORDER BY totalScore DESC`).all(...whereParams) res.json(rows) }) // ═══════════════════════════════════════════════════════════════════════════ // TASK-RULE-004:考核规则 CRUD API // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/rules', (req, res) => { const { status, period_type } = req.query let sql = `SELECT id,code,name,description,period_type AS periodType, period_start AS periodStart,period_end AS periodEnd, status,version,parent_id AS parentId, created_by AS createdBy, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rules WHERE 1=1` const params = [] if (status) { sql += ' AND status = ?'; params.push(status) } if (period_type) { sql += ' AND period_type = ?'; params.push(period_type) } sql += ' ORDER BY created_at DESC' res.json(db.prepare(sql).all(...params)) }) app.get('/api/rules/:id', (req, res) => { const rule = db.prepare(`SELECT id,code,name,description,period_type AS periodType, period_start AS periodStart,period_end AS periodEnd, status,version,parent_id AS parentId, created_by AS createdBy, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rules WHERE id = ?`).get(req.params.id) if (!rule) return res.status(404).json({ message: '规则不存在' }) const items = db.prepare(`SELECT id,rule_id AS ruleId,category,name,metric_key AS metricKey, weight,min_score AS minScore,max_score AS maxScore, formula_type AS formulaType,formula_params AS formulaParams, display_order AS displayOrder,enabled, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rule_items WHERE rule_id = ? ORDER BY display_order`).all(req.params.id) res.json({ ...rule, items }) }) app.post('/api/rules', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可创建考核规则' }) const { name, description, period_type, period_start, period_end, items } = req.body if (!name?.trim()) return res.status(400).json({ message: '规则名称为必填项' }) if (!period_type) return res.status(400).json({ message: '考核周期类型为必填项' }) const code = `RULE_${Date.now()}` db.exec('BEGIN') try { db.prepare(`INSERT INTO rules (code,name,description,period_type,period_start,period_end,status,created_by) VALUES (?,?,?,?,?,?,?,?)`) .run(code, name.trim(), description?.trim() || null, period_type, period_start || null, period_end || null, 'draft', req.user.name) const { id } = db.prepare('SELECT last_insert_rowid() AS id').get() if (items?.length) { for (const item of items) { db.prepare(`INSERT INTO rule_items (rule_id,category,name,metric_key,weight,min_score,max_score,formula_type,formula_params,display_order) VALUES (?,?,?,?,?,?,?,?,?,?)`) .run(id, item.category, item.name, item.metric_key, item.weight, item.min_score ?? 0, item.max_score ?? 100, item.formula_type, JSON.stringify(item.formula_params ?? {}), item.display_order ?? 0) } } db.exec('COMMIT') const rule = db.prepare(`SELECT id,code,name,description,period_type AS periodType, period_start AS periodStart,period_end AS periodEnd, status,version,parent_id AS parentId, created_by AS createdBy, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rules WHERE id = ?`).get(id) const ruleItems = db.prepare(`SELECT id,rule_id AS ruleId,category,name,metric_key AS metricKey, weight,min_score AS minScore,max_score AS maxScore, formula_type AS formulaType,formula_params AS formulaParams, display_order AS displayOrder,enabled, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rule_items WHERE rule_id = ? ORDER BY display_order`).all(id) res.status(201).json({ ...rule, items: ruleItems }) } catch (e) { db.exec('ROLLBACK') res.status(500).json({ message: '服务端处理失败' }) } }) app.patch('/api/rules/:id', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可编辑考核规则' }) const rule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id) if (!rule) return res.status(404).json({ message: '规则不存在' }) if (rule.status === 'active') return res.status(409).json({ message: '已激活的规则不可直接编辑,请新建版本' }) const { name, description, period_start, period_end, items } = req.body const updates = [] const params = [] if (name !== undefined) { updates.push('name = ?'); params.push(name.trim()) } if (description !== undefined) { updates.push('description = ?'); params.push(description?.trim() || null) } if (period_start !== undefined) { updates.push('period_start = ?'); params.push(period_start || null) } if (period_end !== undefined) { updates.push('period_end = ?'); params.push(period_end || null) } db.exec('BEGIN') try { if (updates.length > 0) { updates.push('updated_at = CURRENT_TIMESTAMP') params.push(req.params.id) db.prepare(`UPDATE rules SET ${updates.join(', ')} WHERE id = ?`).run(...params) } if (items !== undefined) { db.prepare('DELETE FROM rule_items WHERE rule_id = ?').run(req.params.id) for (const item of items) { db.prepare(`INSERT INTO rule_items (rule_id,category,name,metric_key,weight,min_score,max_score,formula_type,formula_params,display_order) VALUES (?,?,?,?,?,?,?,?,?,?)`) .run(req.params.id, item.category, item.name, item.metric_key, item.weight, item.min_score ?? 0, item.max_score ?? 100, item.formula_type, JSON.stringify(item.formula_params ?? {}), item.display_order ?? 0) } } db.exec('COMMIT') const updated = db.prepare(`SELECT id,code,name,description,period_type AS periodType, period_start AS periodStart,period_end AS periodEnd, status,version,parent_id AS parentId, created_by AS createdBy, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rules WHERE id = ?`).get(req.params.id) const ruleItems = db.prepare(`SELECT id,rule_id AS ruleId,category,name,metric_key AS metricKey, weight,min_score AS minScore,max_score AS maxScore, formula_type AS formulaType,formula_params AS formulaParams, display_order AS displayOrder,enabled, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rule_items WHERE rule_id = ? ORDER BY display_order`).all(req.params.id) res.json({ ...updated, items: ruleItems }) } catch (e) { db.exec('ROLLBACK') res.status(500).json({ message: '服务端处理失败' }) } }) app.post('/api/rules/:id/activate', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可激活考核规则' }) const rule = db.prepare('SELECT * FROM rules WHERE id = ?').get(req.params.id) if (!rule) return res.status(404).json({ message: '规则不存在' }) if (rule.status === 'active') return res.status(409).json({ message: '该规则已激活' }) db.exec('BEGIN') try { // 同一周期类型的已激活规则全部归档 db.prepare(`UPDATE rules SET status = 'archived', updated_at = CURRENT_TIMESTAMP WHERE period_type = ? AND status = 'active'`).run(rule.period_type) // 激活当前规则 db.prepare(`UPDATE rules SET status = 'active', updated_at = CURRENT_TIMESTAMP WHERE id = ?`) .run(req.params.id) db.exec('COMMIT') const updated = db.prepare(`SELECT id,code,name,description,period_type AS periodType, period_start AS periodStart,period_end AS periodEnd, status,version,parent_id AS parentId, created_by AS createdBy, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt FROM rules WHERE id = ?`).get(req.params.id) res.json(updated) } catch (e) { db.exec('ROLLBACK') res.status(500).json({ message: '服务端处理失败' }) } }) // ═══════════════════════════════════════════════════════════════════════════ // TASK-RULE-005:评分 API + 计算引擎 // ═══════════════════════════════════════════════════════════════════════════ // 指标聚合查询:从 work_records 提取各项指标数据 function buildMetricQuery(ruleId, reporter, station, periodStart, periodEnd) { const sql = `SELECT COUNT(*) AS count_total, SUM(CASE WHEN type = 'video' THEN 1 ELSE 0 END) AS count_video, ROUND(AVG(CASE WHEN score IS NOT NULL THEN score END), 1) AS avg_score, SUM(CASE WHEN type = 'article' THEN 1 ELSE 0 END) * 1.0 / NULLIF(COUNT(*), 0) AS original_ratio, SUM(CASE WHEN occurred_date <= datetime(occurred_date, '+7 days') AND status = 'archived' THEN 1 ELSE 0 END) * 1.0 / NULLIF(COUNT(*), 0) AS on_time_rate, SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) * 1.0 / NULLIF(COUNT(*), 0) AS pass_rate FROM work_records WHERE reporter = ? AND station = ? AND occurred_date >= ? AND occurred_date <= ?` return db.prepare(sql).get(reporter, station, periodStart, periodEnd) } // 计算单个指标项得分 function computeItemScore(item, metricValue) { if (metricValue === null || metricValue === undefined) return 0 const { formula_type, formula_params: rawParams } = item const params = typeof rawParams === 'string' ? JSON.parse(rawParams) : rawParams let score = 0 if (formula_type === 'count') { const { threshold = 10, base_score = 60, bonus_per_item = 4, max_bonus = 20 } = params const over = Math.max(0, metricValue - threshold) score = Math.min(base_score + over * bonus_per_item, base_score + max_bonus) } else if (formula_type === 'avg_score') { score = Math.min(Math.max(metricValue, 0), 100) } else if (formula_type === 'rate') { const { target = 0.85, base_score = 60, bonus_per_pct = 40 } = params const ratio = Math.min(metricValue / target, 1.5) score = Math.min(base_score + (ratio - 1) * bonus_per_pct, 100) } else { score = Math.min(Math.max(metricValue, 0), 100) } return Math.round(score * 100) / 100 } app.get('/api/scores', (req, res) => { const { reporter, station, period, rule_id } = req.query let sql = `SELECT s.id,s.code,s.rule_id AS ruleId,s.reporter,s.station, s.period,s.period_type AS periodType, s.total_score AS totalScore, s.quality_score AS qualityScore, s.quantity_score AS quantityScore, s.efficiency_score AS efficiencyScore, s.compliance_score AS complianceScore, s.item_details AS itemDetails, datetime(s.computed_at,'localtime') AS computedAt, datetime(s.created_at,'localtime') AS createdAt, datetime(s.updated_at,'localtime') AS updatedAt, r.name AS ruleName FROM scores s LEFT JOIN rules r ON s.rule_id = r.id WHERE 1=1` const params = [] if (!req.user.role.includes('headquarters')) { sql += ' AND (s.reporter = ? OR s.station = ?)' params.push(req.user.name, req.user.station) } if (reporter) { sql += ' AND s.reporter = ?'; params.push(reporter) } if (station) { sql += ' AND s.station = ?'; params.push(station) } if (period) { sql += ' AND s.period = ?'; params.push(period) } if (rule_id) { sql += ' AND s.rule_id = ?'; params.push(rule_id) } sql += ' ORDER BY s.computed_at DESC' const rows = db.prepare(sql).all(...params) // 反序列化 item_details for (const r of rows) { if (r.itemDetails) { r.items = JSON.parse(r.itemDetails); delete r.itemDetails } } res.json(rows) }) app.get('/api/scores/:id', (req, res) => { const score = db.prepare(`SELECT s.id,s.code,s.rule_id AS ruleId,s.reporter,s.station, s.period,s.period_type AS periodType, s.total_score AS totalScore, s.quality_score AS qualityScore, s.quantity_score AS quantityScore, s.efficiency_score AS efficiencyScore, s.compliance_score AS complianceScore, s.item_details AS itemDetails, datetime(s.computed_at,'localtime') AS computedAt, datetime(s.created_at,'localtime') AS createdAt, datetime(s.updated_at,'localtime') AS updatedAt, r.name AS ruleName FROM scores s LEFT JOIN rules r ON s.rule_id = r.id WHERE s.id = ?`).get(req.params.id) if (!score) return res.status(404).json({ message: '评分记录不存在' }) if (req.user.role !== 'headquarters' && score.reporter !== req.user.name && score.station !== req.user.station) return res.status(403).json({ message: '无权查看该评分记录' }) if (score.itemDetails) { score.items = JSON.parse(score.itemDetails); delete score.itemDetails } res.json(score) }) app.post('/api/scores/compute', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可触发评分计算' }) const { rule_id, reporters, period, period_type } = req.body if (!rule_id) return res.status(400).json({ message: 'rule_id 为必填项' }) if (!period) return res.status(400).json({ message: 'period 为必填项' }) if (!period_type) return res.status(400).json({ message: 'period_type 为必填项' }) const rule = db.prepare('SELECT * FROM rules WHERE id = ?').get(rule_id) if (!rule) return res.status(404).json({ message: '规则不存在' }) // 确定考核时间范围 const periodStart = rule.period_start || period const periodEnd = rule.period_end || period // 获取待计算记者列表 let targetReporters if (reporters?.length) { targetReporters = reporters } else { targetReporters = db.prepare(`SELECT DISTINCT reporter,station FROM work_records WHERE occurred_date >= ? AND occurred_date <= ? ${req.user.role === 'station' ? 'AND station = ?' : ''}`) .all(...(req.user.role === 'station' ? [periodStart, periodEnd, req.user.station] : [periodStart, periodEnd])) .map(r => ({ reporter: r.reporter, station: r.station })) } if (targetReporters.length === 0) return res.status(201).json({ message: `指定范围(${period})内无记者数据`, results: [] }) const items = db.prepare(`SELECT * FROM rule_items WHERE rule_id = ? AND enabled = 1 ORDER BY display_order`) .all(rule_id) // 按维度分组权重 const catWeights = {} for (const it of items) { catWeights[it.category] = (catWeights[it.category] || 0) + it.weight } const results = [] db.exec('BEGIN') try { for (const { reporter, station } of targetReporters) { const metrics = buildMetricQuery(rule_id, reporter, station, periodStart, periodEnd) let totalScore = 0 const itemDetails = [] const catScores = { quantity: 0, quality: 0, efficiency: 0, compliance: 0 } for (const item of items) { const metricValue = metrics[item.metric_key] ?? null const raw = computeItemScore(item, metricValue) const weighted = raw * item.weight totalScore += weighted catScores[item.category] = (catScores[item.category] || 0) + weighted itemDetails.push({ item_id: item.id, category: item.category, name: item.name, metric_key: item.metric_key, metric_value: metricValue, raw_score: raw, weight: item.weight, weighted_score: Math.round(weighted * 100) / 100, }) } const code = `SCORE_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` db.prepare(`INSERT OR REPLACE INTO scores (code,rule_id,reporter,station,period,period_type,total_score, quality_score,quantity_score,efficiency_score,compliance_score,item_details,computed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) .run(code, rule_id, reporter, station, period, period_type, Math.round(totalScore * 100) / 100, Math.round((catScores.quality || 0) * 100) / 100, Math.round((catScores.quantity || 0) * 100) / 100, Math.round((catScores.efficiency || 0) * 100) / 100, Math.round((catScores.compliance || 0) * 100) / 100, JSON.stringify(itemDetails)) results.push({ reporter, station, totalScore: Math.round(totalScore * 100) / 100 }) } db.exec('COMMIT') res.status(201).json({ message: `已为 ${results.length} 名记者计算评分`, results }) } catch (e) { db.exec('ROLLBACK') res.status(500).json({ message: '服务端处理失败' }) } }) // ═══════════════════════════════════════════════════════════════════════════ // 积分排行榜 // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/leaderboard', (req, res) => { const { period, limit = '50', group_by = 'reporter' } = req.query if (!['reporter', 'station'].includes(group_by)) return res.status(400).json({ message: 'group_by 必须是 reporter 或 station' }) // 先找最新评分记录对应的规则 ID const ruleRow = db.prepare( 'SELECT DISTINCT rule_id FROM scores WHERE period = ? ORDER BY computed_at DESC LIMIT 1' ).get(period || '2026-Q3') if (!ruleRow) return res.json({ period, groupBy: group_by, ranks: [] }) const ruleId = ruleRow.rule_id if (group_by === 'reporter') { // 记者排名:取每位记者 latest score,关联 station 名称 const rows = db.prepare(` SELECT s.reporter AS name, s.station, MAX(s.total_score) AS totalScore, MAX(s.computed_at) AS latestAt FROM scores s WHERE s.period = ? AND s.rule_id = ? AND s.reporter IS NOT NULL GROUP BY s.reporter, s.station ORDER BY totalScore DESC, latestAt ASC LIMIT ?` ).all(period || '2026-Q3', ruleId, Number(limit)) // 标注当前登录人是否在榜上 const me = rows.findIndex(r => r.name === req.user.name) const ranked = rows.map((r, i) => ({ rank: i + 1, ...r })) res.json({ period: period || '2026-Q3', groupBy: 'reporter', myRank: me >= 0 ? me + 1 : null, ranks: ranked }) } else { // 记者站排名:取每站平均总分 const rows = db.prepare(` SELECT s.station AS name, ROUND(AVG(s.total_score), 2) AS avgScore, COUNT(DISTINCT s.reporter) AS reporterCount, ROUND(AVG(s.quality_score),2) AS avgQuality, ROUND(AVG(s.quantity_score),2) AS avgQuantity, ROUND(AVG(s.efficiency_score),2) AS avgEfficiency, ROUND(AVG(s.compliance_score),2) AS avgCompliance FROM scores s WHERE s.period = ? AND s.rule_id = ? AND s.station IS NOT NULL GROUP BY s.station ORDER BY avgScore DESC LIMIT ?` ).all(period || '2026-Q3', ruleId, Number(limit)) const me = rows.findIndex(r => r.name === req.user.station) const ranked = rows.map((r, i) => ({ rank: i + 1, ...r })) res.json({ period: period || '2026-Q3', groupBy: 'station', myRank: me >= 0 ? me + 1 : null, ranks: ranked }) } }) app.get('/api/me/summary', (req, res) => { // 当前登录人的考核汇总:最新周期得分 + 排名 const period = req.query.period || '2026-Q3' const role = req.user.role if (role === 'headquarters') { return res.json({ period, score: null, rank: null, total: 0, message: '总部管理员无个人考核数据' }) } const meField = role === 'station' ? 'station' : 'reporter' const meValue = role === 'station' ? req.user.station : req.user.name const myScore = db.prepare(` SELECT s.total_score AS totalScore, s.quality_score, s.quantity_score, s.efficiency_score, s.compliance_score, s.period, s.computed_at AS computedAt, r.name AS ruleName FROM scores s LEFT JOIN rules r ON s.rule_id = r.id WHERE s.${meField} = ? AND s.period = ? ORDER BY s.computed_at DESC LIMIT 1` ).get(meValue, period) if (!myScore) return res.json({ period, score: null, rank: null, total: 0 }) const total = db.prepare(` SELECT COUNT(*) AS cnt FROM ( SELECT DISTINCT reporter FROM scores WHERE period = ? AND rule_id = ? )` ).get(period, myScore.ruleName ? db.prepare('SELECT id FROM rules WHERE name = ?').get(myScore.ruleName)?.id : 0) const rank = db.prepare(` SELECT COUNT(*) + 1 AS rank FROM scores WHERE period = ? AND rule_id = ? AND (total_score > ? OR (total_score = ? AND computed_at < ?))` ).get(period, myScore.ruleName ? db.prepare('SELECT id FROM rules WHERE name = ?').get(myScore.ruleName)?.id : 0, myScore.totalScore, myScore.totalScore, myScore.computedAt) res.json({ period, score: myScore, rank: rank?.rank ?? null, total: total?.cnt ?? 0, }) }) // ═══════════════════════════════════════════════════════════════════════════ // 通知删除 + 撤回 // ═══════════════════════════════════════════════════════════════════════════ app.delete('/api/notices/:id', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可删除通知' }) const notice = db.prepare('SELECT * FROM notices WHERE id = ?').get(req.params.id) if (!notice) return res.status(404).json({ message: '通知不存在' }) db.exec('BEGIN') try { db.prepare('DELETE FROM notice_receipts WHERE notice_id = ?').run(req.params.id) db.prepare('DELETE FROM notices WHERE id = ?').run(req.params.id) db.exec('COMMIT') logSystem(req.user.role, req.user.name, 'notices', 'delete', 'notice', req.params.id, `删除通知: ${notice.title}`) res.json({ message: '删除成功' }) } catch (e) { db.exec('ROLLBACK') res.status(500).json({ message: '删除失败' }) } }) // 撤回通知 app.post('/api/notices/:id/withdraw', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可撤回通知' }) const notice = db.prepare('SELECT * FROM notices WHERE id = ?').get(req.params.id) if (!notice) return res.status(404).json({ message: '通知不存在' }) if (notice.status === 'withdrawn') return res.status(409).json({ message: '该通知已撤回' }) db.prepare(`UPDATE notices SET status = 'withdrawn', withdrawn_at = datetime('now','localtime'), updated_at = datetime('now','localtime') WHERE id = ?`).run(req.params.id) logSystem(req.user.role, req.user.name, 'notices', 'withdraw', 'notice', req.params.id, `撤回通知: ${notice.title}`) res.json({ message: '通知已撤回' }) }) // ═══════════════════════════════════════════════════════════════════════════ // 申诉复议 API // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/appeals', (req, res) => { let sql = `SELECT id,code,record_id AS recordId,appellant,station,reason,status, handler,handler_role AS handlerRole,response, datetime(created_at,'localtime') AS createdAt, datetime(updated_at,'localtime') AS updatedAt, datetime(handled_at,'localtime') AS handledAt FROM appeals WHERE 1=1` const params = [] if (req.user.role === 'reporter') { sql += ' AND appellant = ?'; params.push(req.user.name) } if (req.user.role === 'station') { sql += ' AND station = ?'; params.push(req.user.station) } if (req.query.status) { sql += ' AND status = ?'; params.push(req.query.status) } sql += ' ORDER BY created_at DESC' res.json(db.prepare(sql).all(...params)) }) app.post('/api/appeals', (req, res) => { const { recordId, reason } = req.body if (!recordId || !reason?.trim()) return res.status(400).json({ message: '记录ID和申诉原因为必填项' }) const record = db.prepare(`${recordSelect} AND id = ?`).get(recordId) if (!record) return res.status(404).json({ message: '工作记录不存在' }) if (record.reporter !== req.user.name && req.user.role !== 'headquarters') return res.status(403).json({ message: '仅填报人可申诉' }) if (record.status !== 'archived' && record.status !== 'returned') return res.status(409).json({ message: '仅已归档或已退回的记录可申诉' }) const code = `APPEAL_${Date.now()}` db.prepare(`INSERT INTO appeals (code,record_id,appellant,station,reason,status) VALUES (?,?,?,?,?,?)`).run(code, recordId, req.user.name, record.station, reason.trim(), 'pending') logSystem(req.user.role, req.user.name, 'appeals', 'create', 'appeal', code, `申诉记录: ${recordId}`) res.status(201).json({ message: '申诉已提交', code }) }) app.patch('/api/appeals/:id', (req, res) => { if (req.user.role === 'reporter') return res.status(403).json({ message: '记者无权处理申诉' }) const appeal = db.prepare('SELECT * FROM appeals WHERE id = ?').get(req.params.id) if (!appeal) return res.status(404).json({ message: '申诉不存在' }) if (appeal.status !== 'pending') return res.status(409).json({ message: '该申诉已处理' }) const { decision, response } = req.body if (!['uphold', 'overturn'].includes(decision)) return res.status(400).json({ message: 'decision 必须是 uphold 或 overturn' }) const status = decision === 'uphold' ? 'rejected' : 'accepted' db.prepare(`UPDATE appeals SET status = ?, handler = ?, handler_role = ?, response = ?, handled_at = datetime('now','localtime'), updated_at = datetime('now','localtime') WHERE id = ?`) .run(status, req.user.name, req.user.role, response?.trim() || null, req.params.id) // 如果复议成功,退回记录到填报人修改 if (decision === 'overturn') { db.prepare(`UPDATE work_records SET status = 'returned', updated_at = CURRENT_TIMESTAMP WHERE id = ?`).run(appeal.record_id) } logSystem(req.user.role, req.user.name, 'appeals', decision, 'appeal', req.params.id, `处理申诉: ${appeal.code}`) res.json({ message: decision === 'uphold' ? '申诉已驳回' : '申诉已通过,记录已退回' }) }) // ═══════════════════════════════════════════════════════════════════════════ // 人员调站 // ═══════════════════════════════════════════════════════════════════════════ app.post('/api/people/:id/transfer', (req, res) => { if (req.user.role !== 'headquarters') return res.status(403).json({ message: '仅总部管理员可调站' }) const person = db.prepare('SELECT * FROM people WHERE id = ? AND deleted_at IS NULL').get(req.params.id) if (!person) return res.status(404).json({ message: '人员不存在' }) const { toStation, reason } = req.body if (!toStation?.trim()) return res.status(400).json({ message: '目标站点为必填项' }) const fromStation = person.station db.exec('BEGIN') try { db.prepare('UPDATE people SET station = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(toStation.trim(), req.params.id) db.prepare(`INSERT INTO person_transfers (person_id,from_station,to_station,reason,operated_by) VALUES (?,?,?,?,?)`).run(req.params.id, fromStation, toStation.trim(), reason?.trim() || null, req.user.name) db.exec('COMMIT') logSystem(req.user.role, req.user.name, 'people', 'transfer', 'person', req.params.id, `${person.name}: ${fromStation} -> ${toStation}`) res.json({ message: `已将 ${person.name} 从 ${fromStation} 调至 ${toStation}` }) } catch (e) { db.exec('ROLLBACK') res.status(500).json({ message: '调站失败' }) } }) app.get('/api/people/:id/transfers', (req, res) => { const transfers = db.prepare(`SELECT id,from_station AS fromStation,to_station AS toStation,reason,operated_by AS operatedBy, datetime(transferred_at,'localtime') AS transferredAt FROM person_transfers WHERE person_id = ? ORDER BY transferred_at DESC`).all(req.params.id) res.json(transfers) }) // ═══════════════════════════════════════════════════════════════════════════ // 导出 API(CSV 格式) // ═══════════════════════════════════════════════════════════════════════════ function csvEscape(value) { if (value === null || value === undefined) return '' const str = String(value) if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"` } return str } app.get('/api/export/records', (req, res) => { let sql = recordSelect const params = [] if (req.user.role === 'station') { sql += ' AND station = ?'; params.push(req.user.station) } if (req.user.role === 'reporter') { sql += ' AND reporter = ?'; params.push(req.user.name) } if (req.query.startDate) { sql += ' AND occurred_date >= ?'; params.push(req.query.startDate) } if (req.query.endDate) { sql += ' AND occurred_date <= ?'; params.push(req.query.endDate) } if (req.query.type) { sql += ' AND type = ?'; params.push(req.query.type) } if (req.query.status) { sql += ' AND status = ?'; params.push(req.query.status) } sql += ' ORDER BY updated_at DESC' const rows = db.prepare(sql).all(...params) const headers = ['ID', '标题', '类型', '填报人', '站点', '日期', '平台', '状态', '得分', '说明'] const csv = [headers.join(',')] for (const r of rows) { csv.push([r.id, r.title, r.type, r.reporter, r.station, r.date, r.platform, r.status, r.score ?? '', r.description ?? ''].map(csvEscape).join(',')) } logSystem(req.user.role, req.user.name, 'export', 'records', 'work_records', null, `导出 ${rows.length} 条记录`) res.setHeader('Content-Type', 'text/csv; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="work_records_${Date.now()}.csv"`) res.send('\ufeff' + csv.join('\n')) }) app.get('/api/export/people', (req, res) => { if (req.user.role === 'reporter') return res.status(403).json({ message: '无权导出人员数据' }) let sql = `SELECT id,code,name,station,title,phone,joined_at AS joinedAt,status FROM people WHERE deleted_at IS NULL` const params = [] if (req.user.role === 'station') { sql += ' AND station = ?'; params.push(req.user.station) } sql += ' ORDER BY station, name' const rows = db.prepare(sql).all(...params) const headers = ['ID', '工号', '姓名', '站点', '职务', '手机号', '入站时间', '状态'] const csv = [headers.join(',')] for (const r of rows) { csv.push([r.id, r.code, r.name, r.station, r.title ?? '', r.phone ?? '', r.joinedAt ?? '', r.status].map(csvEscape).join(',')) } logSystem(req.user.role, req.user.name, 'export', 'people', 'people', null, `导出 ${rows.length} 条人员`) res.setHeader('Content-Type', 'text/csv; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="people_${Date.now()}.csv"`) res.send('\ufeff' + csv.join('\n')) }) app.get('/api/export/scores', (req, res) => { const { period } = req.query let sql = `SELECT s.reporter,s.station,s.period,s.total_score AS totalScore, s.quality_score AS qualityScore,s.quantity_score AS quantityScore, s.efficiency_score AS efficiencyScore,s.compliance_score AS complianceScore, r.name AS ruleName FROM scores s LEFT JOIN rules r ON s.rule_id = r.id WHERE 1=1` const params = [] if (!req.user.role.includes('headquarters')) { sql += ' AND (s.reporter = ? OR s.station = ?)' params.push(req.user.name, req.user.station) } if (period) { sql += ' AND s.period = ?'; params.push(period) } sql += ' ORDER BY s.total_score DESC' const rows = db.prepare(sql).all(...params) const headers = ['记者', '站点', '周期', '总分', '质量得分', '数量得分', '效率得分', '合规得分', '规则'] const csv = [headers.join(',')] for (const r of rows) { csv.push([r.reporter, r.station, r.period, r.totalScore ?? '', r.qualityScore ?? '', r.quantityScore ?? '', r.efficiencyScore ?? '', r.complianceScore ?? '', r.ruleName ?? ''].map(csvEscape).join(',')) } logSystem(req.user.role, req.user.name, 'export', 'scores', 'scores', null, `导出 ${rows.length} 条评分`) res.setHeader('Content-Type', 'text/csv; charset=utf-8') res.setHeader('Content-Disposition', `attachment; filename="scores_${Date.now()}.csv"`) res.send('\ufeff' + csv.join('\n')) }) // ═══════════════════════════════════════════════════════════════════════════ // 错误处理 // ═══════════════════════════════════════════════════════════════════════════ app.use((err, _req, res, _next) => { console.error(err) res.status(500).json({ message: '服务端处理失败' }) }) // ── 启动 ────────────────────────────────────────────────────────────────── const port = Number(process.env.API_PORT || 8787) app.listen(port, '127.0.0.1', () => console.log(`API ready at http://127.0.0.1:${port}`))