Files
2026-08-01 23:09:49 +08:00

1426 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
// ═══════════════════════════════════════════════════════════════════════════
// 导出 APICSV 格式)
// ═══════════════════════════════════════════════════════════════════════════
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}`))