fix:第一次提交,当前还有很多BUG

This commit is contained in:
2026-07-09 00:25:37 +08:00
commit 21a772a7a5
45 changed files with 10511 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
import { spawn } from 'node:child_process'
const REMBG_COMMAND = process.env.REMBG_COMMAND ?? 'rembg'
const REMBG_TIMEOUT_MS = Number(process.env.REMBG_TIMEOUT_MS ?? 120000)
export class BackgroundRemovalError extends Error {
constructor(message: string, readonly statusCode = 500) {
super(message)
this.name = 'BackgroundRemovalError'
}
}
export function removeImageBackground(input: Buffer) {
return new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = []
const errorChunks: Buffer[] = []
const child = spawn(REMBG_COMMAND, ['i'], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
const timeout = setTimeout(() => {
child.kill('SIGTERM')
reject(new BackgroundRemovalError('背景移除超时,请稍后重试', 504))
}, REMBG_TIMEOUT_MS)
child.stdout.on('data', (chunk: Buffer) => {
chunks.push(chunk)
})
child.stderr.on('data', (chunk: Buffer) => {
errorChunks.push(chunk)
})
child.on('error', (error: NodeJS.ErrnoException) => {
clearTimeout(timeout)
if (error.code === 'ENOENT') {
reject(new BackgroundRemovalError('服务器未安装 rembg,请先安装后再使用背景移除功能', 503))
return
}
reject(error)
})
child.on('close', (code) => {
clearTimeout(timeout)
if (code !== 0) {
const stderr = Buffer.concat(errorChunks).toString('utf8').trim()
reject(new BackgroundRemovalError(stderr || `rembg 处理失败,退出码 ${code}`))
return
}
const output = Buffer.concat(chunks)
if (output.length === 0) {
reject(new BackgroundRemovalError('rembg 未返回图片结果'))
return
}
resolve(output)
})
child.stdin.end(input)
})
}
+88
View File
@@ -0,0 +1,88 @@
import express from 'express'
import { BackgroundRemovalError, removeImageBackground } from './background-removal.js'
import { readMediaFile } from './media-storage.js'
import { loadAppState, saveAppState } from './storage.js'
const PORT = Number(process.env.PORT ?? 4000)
const app = express()
app.use((_request, response, next) => {
response.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
response.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
response.setHeader('Cross-Origin-Resource-Policy', 'same-origin')
next()
})
app.use(express.json({ limit: '100mb' }))
app.get('/api/health', (_request, response) => {
response.json({ ok: true })
})
app.post(
'/api/background-removal',
express.raw({
limit: '25mb',
type: ['image/jpeg', 'image/png', 'image/webp', 'application/octet-stream']
}),
async (request, response, next) => {
try {
if (!Buffer.isBuffer(request.body) || request.body.length === 0) {
response.status(400).json({ error: '请上传需要处理的图片' })
return
}
const output = await removeImageBackground(request.body)
response.setHeader('Cache-Control', 'no-store')
response.setHeader('Content-Type', 'image/png')
response.send(output)
} catch (error) {
next(error)
}
}
)
app.get('/api/storage', async (_request, response, next) => {
try {
response.json(await loadAppState())
} catch (error) {
next(error)
}
})
app.put('/api/storage', async (request, response, next) => {
try {
await saveAppState(request.body)
response.json({ ok: true })
} catch (error) {
next(error)
}
})
app.get('/api/storage/media/*path', (request, response) => {
const pathParam = request.params.path
const segments = Array.isArray(pathParam) ? pathParam : String(pathParam).split('/')
const media = readMediaFile(segments)
if (!media) {
response.status(404).send('Not found')
return
}
response.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
response.setHeader('Content-Type', media.contentType)
response.send(media.bytes)
})
app.use((error: unknown, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
console.error(error)
response.status(error instanceof BackgroundRemovalError ? error.statusCode : 500).json({
error: error instanceof Error ? error.message : 'Internal server error'
})
})
app.listen(PORT, () => {
console.log(`Graduation API listening on http://127.0.0.1:${PORT}`)
})
+117
View File
@@ -0,0 +1,117 @@
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { ASSET_DIR, MEDIA_DIR } from './paths.js'
const MEDIA_URL_PREFIX = '/api/storage/media'
const MIME_EXTENSIONS: Record<string, 'jpg' | 'png'> = {
'image/jpeg': 'jpg',
'image/png': 'png'
}
const EXTENSION_CONTENT_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png'
}
type MediaFile = {
bytes: Buffer
contentType: string
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function saveDataUrl(dataUrl: string) {
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/)
if (!match) {
return dataUrl
}
const declaredContentType = match[1]
const bytes = Buffer.from(match[2], 'base64')
const sniffedExtension =
bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff
? 'jpg'
: bytes[0] === 0x89 &&
bytes[1] === 0x50 &&
bytes[2] === 0x4e &&
bytes[3] === 0x47
? 'png'
: undefined
const extension = MIME_EXTENSIONS[declaredContentType] ?? sniffedExtension
if (!extension) {
return dataUrl
}
const hash = createHash('sha256').update(bytes).digest('hex')
const fileName = `${hash}.${extension}`
const filePath = path.join(ASSET_DIR, fileName)
mkdirSync(ASSET_DIR, { recursive: true })
if (!existsSync(filePath)) {
writeFileSync(filePath, bytes)
}
return `${MEDIA_URL_PREFIX}/assets/${fileName}`
}
export function externalizeMedia(value: unknown): unknown {
if (typeof value === 'string') {
return value.startsWith('data:') ? saveDataUrl(value) : value
}
if (Array.isArray(value)) {
return value.map((item) => externalizeMedia(item))
}
if (!isPlainObject(value)) {
return value
}
const result: Record<string, unknown> = {}
for (const [key, nestedValue] of Object.entries(value)) {
if (key === 'blob' || key === 'jpg') {
continue
}
result[key] = externalizeMedia(nestedValue)
}
return result
}
export function readMediaFile(segments: string[]): MediaFile | null {
if (
segments.length === 0 ||
segments.some((segment) => !segment || segment === '.' || segment === '..')
) {
return null
}
const requestedPath = path.join(MEDIA_DIR, ...segments)
const relativePath = path.relative(MEDIA_DIR, requestedPath)
if (relativePath.startsWith('..') || path.isAbsolute(relativePath) || !existsSync(requestedPath)) {
return null
}
const contentType = EXTENSION_CONTENT_TYPES[path.extname(requestedPath).toLowerCase()]
if (!contentType) {
return null
}
return {
bytes: readFileSync(requestedPath),
contentType
}
}
+8
View File
@@ -0,0 +1,8 @@
import path from 'node:path'
export const WORKSPACE_ROOT = path.resolve(process.cwd(), '../..')
export const DATA_DIR = path.join(WORKSPACE_ROOT, 'data')
export const DB_PATH = path.join(DATA_DIR, 'graduation.sqlite')
export const MEDIA_DIR = path.join(DATA_DIR, 'media')
export const ASSET_DIR = path.join(MEDIA_DIR, 'assets')
export const SQL_WASM_PATH = path.join(WORKSPACE_ROOT, 'node_modules', 'sql.js', 'dist')
+492
View File
@@ -0,0 +1,492 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { externalizeMedia } from './media-storage.js'
import { DATA_DIR, DB_PATH, SQL_WASM_PATH } from './paths.js'
type PersistedAppState = {
templates: Array<{
id: string
name: string
image: unknown
config: unknown
updatedAt: string
}>
activeTemplateId: string
classes: Array<{
id: string
name: string
students: Array<{
id: string
name: string
photo?: unknown
photoFileName?: string
idPhoto?: unknown
idPhotoFileName?: string
idPhotoCrop?: unknown
}>
idPhotoConfig?: unknown
createdAt: string
}>
activeClassId: string
certificates: Array<{
id: string
classId: string
className: string
studentId: string
studentName: string
photo: unknown
template: PersistedAppState['templates'][number]
asset: {
fileName: string
}
jpgUrl?: string
updatedAt: string
}>
}
type SqlJsStatement = {
run(values?: unknown[]): void
free(): void
}
type SqlJsDatabase = {
run(sql: string): void
exec(sql: string): Array<{ columns: string[]; values: unknown[][] }>
prepare(sql: string): SqlJsStatement
export(): Uint8Array
}
type SqlJsStatic = {
Database: new (data?: Uint8Array | Buffer) => SqlJsDatabase
}
let databasePromise: Promise<SqlJsDatabase> | undefined
const EMPTY_STATE: PersistedAppState = {
templates: [],
activeTemplateId: '',
classes: [],
activeClassId: '',
certificates: []
}
function asString(value: unknown) {
return typeof value === 'string' ? value : ''
}
function asNumber(value: unknown) {
return typeof value === 'number' ? value : 0
}
function json<T>(value: string, fallback: T): T {
try {
return JSON.parse(value) as T
} catch {
return fallback
}
}
function rows<T extends Record<string, unknown>>(database: SqlJsDatabase, sql: string): T[] {
const result = database.exec(sql)[0]
if (!result) {
return []
}
return result.values.map((valueRow) =>
Object.fromEntries(result.columns.map((column, index) => [column, valueRow[index]]))
) as T[]
}
function scalar(database: SqlJsDatabase, sql: string) {
return database.exec(sql)[0]?.values[0]?.[0]
}
function persist(database: SqlJsDatabase) {
writeFileSync(DB_PATH, Buffer.from(database.export()))
}
async function db() {
if (!databasePromise) {
databasePromise = (async () => {
mkdirSync(DATA_DIR, { recursive: true })
const runtimeRequire = createRequire(import.meta.url)
const initSqlJs = runtimeRequire('sql.js') as (options?: {
locateFile?: (file: string) => string
}) => Promise<SqlJsStatic>
const SQL = await initSqlJs({
locateFile: (file) => path.join(SQL_WASM_PATH, file)
})
const database = existsSync(DB_PATH)
? new SQL.Database(readFileSync(DB_PATH))
: new SQL.Database()
database.run(`
CREATE TABLE IF NOT EXISTS app_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`)
database.run(`
CREATE TABLE IF NOT EXISTS templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
image_json TEXT NOT NULL,
config_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`)
database.run(`
CREATE TABLE IF NOT EXISTS classes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
id_photo_config_json TEXT,
created_at TEXT NOT NULL
);
`)
database.run(`
CREATE TABLE IF NOT EXISTS students (
id TEXT PRIMARY KEY,
class_id TEXT NOT NULL,
name TEXT NOT NULL,
photo_json TEXT,
photo_file_name TEXT,
id_photo_json TEXT,
id_photo_file_name TEXT,
id_photo_crop_json TEXT,
sort_order INTEGER NOT NULL,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE
);
`)
const classColumns = rows<{ name: string }>(database, 'PRAGMA table_info(classes)').map((column) => asString(column.name))
const studentColumns = rows<{ name: string }>(database, 'PRAGMA table_info(students)').map((column) => asString(column.name))
if (!classColumns.includes('id_photo_config_json')) {
database.run('ALTER TABLE classes ADD COLUMN id_photo_config_json TEXT')
}
if (!studentColumns.includes('id_photo_json')) {
database.run('ALTER TABLE students ADD COLUMN id_photo_json TEXT')
}
if (!studentColumns.includes('id_photo_file_name')) {
database.run('ALTER TABLE students ADD COLUMN id_photo_file_name TEXT')
}
if (!studentColumns.includes('id_photo_crop_json')) {
database.run('ALTER TABLE students ADD COLUMN id_photo_crop_json TEXT')
}
database.run(`
CREATE TABLE IF NOT EXISTS generated_certificates (
id TEXT PRIMARY KEY,
class_id TEXT NOT NULL,
student_id TEXT NOT NULL,
template_id TEXT,
class_name TEXT NOT NULL,
student_name TEXT NOT NULL,
asset_file_name TEXT NOT NULL,
jpg_url TEXT,
updated_at TEXT NOT NULL,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE CASCADE,
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE SET NULL
);
`)
persist(database)
return database
})()
}
return databasePromise
}
function runPrepared(
database: SqlJsDatabase,
sql: string,
run: (statement: SqlJsStatement) => void
) {
const statement = database.prepare(sql)
try {
run(statement)
} finally {
statement.free()
}
}
function hasStructuredState(database: SqlJsDatabase) {
const [{ count = 0 } = { count: 0 }] = rows<{ count: number }>(
database,
`
SELECT
(SELECT COUNT(*) FROM templates) +
(SELECT COUNT(*) FROM classes) +
(SELECT COUNT(*) FROM students) +
(SELECT COUNT(*) FROM generated_certificates) AS count
`
)
return count > 0
}
export async function loadAppState(): Promise<PersistedAppState> {
const database = await db()
const value = scalar(database, 'SELECT json FROM app_state WHERE id = 1 LIMIT 1')
const savedState = json(asString(value), EMPTY_STATE)
if (!hasStructuredState(database)) {
if (
savedState.templates?.length ||
savedState.classes?.length ||
savedState.certificates?.length
) {
await saveAppState(savedState)
return loadStructuredState(database, savedState)
}
return {
...EMPTY_STATE,
activeTemplateId: savedState.activeTemplateId ?? '',
activeClassId: savedState.activeClassId ?? ''
}
}
const structuredState = loadStructuredState(database, savedState)
const normalizedState = externalizeMedia(structuredState) as PersistedAppState
if (JSON.stringify(normalizedState) !== JSON.stringify(structuredState)) {
await saveAppState(normalizedState)
}
return normalizedState
}
export async function saveAppState(state: PersistedAppState): Promise<void> {
const database = await db()
const previousAppStateBytes = asNumber(
scalar(database, 'SELECT length(json) FROM app_state WHERE id = 1 LIMIT 1')
)
const normalizedState = externalizeMedia(state) as PersistedAppState
const updatedAt = new Date().toISOString()
const activeState = {
activeTemplateId: normalizedState.activeTemplateId,
activeClassId: normalizedState.activeClassId
}
try {
database.run('BEGIN TRANSACTION')
database.run('DELETE FROM generated_certificates')
database.run('DELETE FROM students')
database.run('DELETE FROM classes')
database.run('DELETE FROM templates')
runPrepared(database, 'INSERT INTO templates (id, name, image_json, config_json, updated_at) VALUES (?, ?, ?, ?, ?)', (statement) => {
for (const template of normalizedState.templates) {
statement.run([
template.id,
template.name,
JSON.stringify(template.image),
JSON.stringify(template.config),
template.updatedAt
])
}
})
runPrepared(database, 'INSERT INTO classes (id, name, id_photo_config_json, created_at) VALUES (?, ?, ?, ?)', (statement) => {
for (const classItem of normalizedState.classes) {
statement.run([
classItem.id,
classItem.name,
classItem.idPhotoConfig ? JSON.stringify(classItem.idPhotoConfig) : null,
classItem.createdAt
])
}
})
runPrepared(
database,
'INSERT INTO students (id, class_id, name, photo_json, photo_file_name, id_photo_json, id_photo_file_name, id_photo_crop_json, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
(statement) => {
for (const classItem of normalizedState.classes) {
classItem.students.forEach((student, index) => {
statement.run([
student.id,
classItem.id,
student.name,
student.photo ? JSON.stringify(student.photo) : null,
student.photoFileName ?? null,
student.idPhoto ? JSON.stringify(student.idPhoto) : null,
student.idPhotoFileName ?? null,
student.idPhotoCrop ? JSON.stringify(student.idPhotoCrop) : null,
index
])
})
}
}
)
runPrepared(
database,
'INSERT INTO generated_certificates (id, class_id, student_id, template_id, class_name, student_name, asset_file_name, jpg_url, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
(statement) => {
for (const certificate of normalizedState.certificates) {
statement.run([
certificate.id,
certificate.classId,
certificate.studentId,
certificate.template.id,
certificate.className,
certificate.studentName,
certificate.asset.fileName,
certificate.jpgUrl ?? null,
certificate.updatedAt
])
}
}
)
runPrepared(
database,
'INSERT OR REPLACE INTO app_state (id, json, updated_at) VALUES (1, ?, ?)',
(statement) => {
statement.run([JSON.stringify(activeState), updatedAt])
}
)
database.run('COMMIT')
} catch (error) {
database.run('ROLLBACK')
throw error
}
if (previousAppStateBytes > 1_000_000) {
database.run('VACUUM')
}
persist(database)
}
function loadStructuredState(
database: SqlJsDatabase,
savedState: Partial<PersistedAppState> = EMPTY_STATE
): PersistedAppState {
const templates = rows<{
id: string
name: string
image_json: string
config_json: string
updated_at: string
}>(database, 'SELECT id, name, image_json, config_json, updated_at FROM templates ORDER BY updated_at ASC').map(
(template) => ({
id: asString(template.id),
name: asString(template.name),
image: json(asString(template.image_json), EMPTY_STATE.templates[0]?.image),
config: json(asString(template.config_json), EMPTY_STATE.templates[0]?.config),
updatedAt: asString(template.updated_at)
})
)
const studentsByClass = new Map<string, PersistedAppState['classes'][number]['students']>()
rows<{
id: string
class_id: string
name: string
photo_json: string | null
photo_file_name: string | null
id_photo_json: string | null
id_photo_file_name: string | null
id_photo_crop_json: string | null
sort_order: number
}>(
database,
'SELECT id, class_id, name, photo_json, photo_file_name, id_photo_json, id_photo_file_name, id_photo_crop_json, sort_order FROM students ORDER BY class_id ASC, sort_order ASC'
).forEach((student) => {
const classId = asString(student.class_id)
const classStudents = studentsByClass.get(classId) ?? []
const photoJson = asString(student.photo_json)
const idPhotoJson = asString(student.id_photo_json)
const idPhotoCropJson = asString(student.id_photo_crop_json)
classStudents.push({
id: asString(student.id),
name: asString(student.name),
photo: photoJson ? json(photoJson, undefined) : undefined,
photoFileName: asString(student.photo_file_name) || undefined,
idPhoto: idPhotoJson ? json(idPhotoJson, undefined) : undefined,
idPhotoFileName: asString(student.id_photo_file_name) || undefined,
idPhotoCrop: idPhotoCropJson ? json(idPhotoCropJson, undefined) : undefined
})
studentsByClass.set(classId, classStudents)
})
const classes = rows<{
id: string
name: string
id_photo_config_json: string | null
created_at: string
}>(database, 'SELECT id, name, id_photo_config_json, created_at FROM classes ORDER BY created_at ASC').map((classItem) => ({
id: asString(classItem.id),
name: asString(classItem.name),
students: studentsByClass.get(asString(classItem.id)) ?? [],
idPhotoConfig: json(asString(classItem.id_photo_config_json), undefined),
createdAt: asString(classItem.created_at)
}))
const templatesById = new Map(templates.map((template) => [template.id, template]))
const classesById = new Map(classes.map((classItem) => [classItem.id, classItem]))
const studentsById = new Map(classes.flatMap((classItem) => classItem.students.map((student) => [student.id, student])))
const certificates = rows<{
id: string
class_id: string
student_id: string
template_id: string | null
class_name: string
student_name: string
asset_file_name: string
jpg_url: string | null
updated_at: string
}>(
database,
'SELECT id, class_id, student_id, template_id, class_name, student_name, asset_file_name, jpg_url, updated_at FROM generated_certificates ORDER BY updated_at ASC'
).flatMap((certificate) => {
const classId = asString(certificate.class_id)
const studentId = asString(certificate.student_id)
const template = templatesById.get(asString(certificate.template_id)) ?? templates[0]
const student = studentsById.get(studentId)
const photo = student?.photo
if (!template || !photo) {
return []
}
return [
{
id: asString(certificate.id),
classId,
className: asString(certificate.class_name) || classesById.get(classId)?.name || '',
studentId,
studentName: asString(certificate.student_name) || student?.name || '',
photo,
template,
asset: {
fileName: asString(certificate.asset_file_name)
},
jpgUrl: asString(certificate.jpg_url) || undefined,
updatedAt: asString(certificate.updated_at)
}
]
})
return {
templates,
activeTemplateId: savedState.activeTemplateId ?? templates[0]?.id ?? '',
classes,
activeClassId: savedState.activeClassId ?? classes[0]?.id ?? '',
certificates
}
}