先提交上去

This commit is contained in:
Fox_block
2026-03-16 20:49:46 +08:00
parent 86abcb5602
commit 192b48cab0
303 changed files with 30019 additions and 5846 deletions
+245
View File
@@ -0,0 +1,245 @@
import { Context, Service } from '../../shared/kernel'
import { DataSource } from 'typeorm'
import path from 'path'
import fs from 'fs'
import { StudentEntity } from './entities/StudentEntity'
import { ReasonEntity } from './entities/ReasonEntity'
import { ScoreEventEntity } from './entities/ScoreEventEntity'
import { SettlementEntity } from './entities/SettlementEntity'
import { SettingEntity } from './entities/SettingEntity'
import { TagEntity } from './entities/TagEntity'
import { StudentTagEntity } from './entities/StudentTagEntity'
import { migrations } from './migrations'
declare module '../../shared/kernel' {
interface Context {
db: DbManager
}
}
interface PostgreSQLConfig {
host: string
port: number
username: string
password: string
database: string
ssl?: boolean
sslmode?: string
channelBinding?: string
}
interface QueueItem {
id: string
operation: () => Promise<any>
resolve: (value: any) => void
reject: (error: Error) => void
timestamp: number
}
export function parsePostgreSQLConnectionString(connectionString: string): PostgreSQLConfig | null {
if (!connectionString.startsWith('postgresql://')) {
return null
}
try {
const url = new URL(connectionString)
const config: PostgreSQLConfig = {
host: url.hostname,
port: url.port ? parseInt(url.port, 10) : 5432,
username: url.username,
password: decodeURIComponent(url.password || ''),
database: url.pathname.slice(1),
ssl: false
}
const params = new URLSearchParams(url.search)
if (params.get('sslmode')) {
config.ssl = true
config.sslmode = params.get('sslmode') || 'require'
}
if (params.get('channel_binding')) {
config.channelBinding = params.get('channel_binding') || 'require'
}
return config
} catch (e) {
console.error('Failed to parse PostgreSQL connection string:', e)
return null
}
}
export class DbManager extends Service {
private _dataSource: DataSource | null = null
private _isPostgreSQL: boolean = false
private dbPath: string
private operationQueue: QueueItem[] = []
private isProcessingQueue: boolean = false
private queueIdCounter: number = 0
constructor(ctx: Context, dbPath: string, pgConnectionString?: string) {
super(ctx, 'db')
this.dbPath = dbPath
this._isPostgreSQL = !!pgConnectionString
}
get dataSource(): DataSource {
if (!this._dataSource) {
throw new Error('Database not initialized. Call initialize() first.')
}
return this._dataSource
}
get isPostgreSQL(): boolean {
return this._isPostgreSQL
}
private createDataSource(pgConnectionString?: string): DataSource {
const pgConfig = pgConnectionString ? parsePostgreSQLConnectionString(pgConnectionString) : null
if (pgConfig) {
this._isPostgreSQL = true
return new DataSource({
type: 'postgres',
host: pgConfig.host,
port: pgConfig.port,
username: pgConfig.username,
password: pgConfig.password,
database: pgConfig.database,
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : false,
extra: {
ssl: pgConfig.ssl ? { rejectUnauthorized: false } : undefined,
sslmode: pgConfig.sslmode,
channelBinding: pgConfig.channelBinding
},
entities: [
StudentEntity,
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
migrations,
synchronize: false,
logging: false,
poolSize: 10,
connectTimeoutMS: 30000
})
} else {
this._isPostgreSQL = false
const dbDir = path.dirname(this.dbPath)
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true })
}
return new DataSource({
type: 'better-sqlite3',
database: this.dbPath,
entities: [
StudentEntity,
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
TagEntity,
StudentTagEntity
],
migrations,
synchronize: false,
logging: false
})
}
}
async initialize(pgConnectionString?: string) {
if (this._dataSource?.isInitialized) {
await this.dispose()
}
this._dataSource = this.createDataSource(pgConnectionString)
await this._dataSource.initialize()
if (!this._isPostgreSQL) {
await this._dataSource.query('PRAGMA foreign_keys = ON')
}
await this._dataSource.runMigrations()
}
async dispose() {
if (!this._dataSource?.isInitialized) return
await this._dataSource.destroy()
this._dataSource = null
}
async switchConnection(pgConnectionString?: string): Promise<{ type: 'sqlite' | 'postgresql' }> {
await this.dispose()
await this.initialize(pgConnectionString)
return { type: this._isPostgreSQL ? 'postgresql' : 'sqlite' }
}
getDatabaseType(): 'sqlite' | 'postgresql' {
return this._isPostgreSQL ? 'postgresql' : 'sqlite'
}
async enqueueOperation<T>(operation: () => Promise<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const item: QueueItem = {
id: `op-${++this.queueIdCounter}`,
operation,
resolve: resolve as (value: any) => void,
reject,
timestamp: Date.now()
}
this.operationQueue.push(item)
this.processQueue()
})
}
private async processQueue() {
if (this.isProcessingQueue || this.operationQueue.length === 0) {
return
}
this.isProcessingQueue = true
while (this.operationQueue.length > 0) {
const item = this.operationQueue.shift()!
try {
const result = await item.operation()
item.resolve(result)
} catch (error) {
item.reject(error as Error)
}
}
this.isProcessingQueue = false
}
async withQueue<T>(operation: () => Promise<T>): Promise<T> {
if (this._isPostgreSQL) {
return this.enqueueOperation(operation)
}
return operation()
}
async syncToRemote(): Promise<{ success: boolean; message?: string }> {
if (!this._isPostgreSQL) {
return { success: false, message: '当前不是远程数据库模式' }
}
if (!this._dataSource?.isInitialized) {
return { success: false, message: '数据库未初始化' }
}
try {
return await this.enqueueOperation(async () => {
await this._dataSource!.query('SELECT 1')
return { success: true, message: '同步成功' }
})
} catch (error: any) {
return { success: false, message: error?.message || '同步失败' }
}
}
}
@@ -0,0 +1,170 @@
import type { DataSource } from 'typeorm'
import {
ReasonEntity,
ScoreEventEntity,
SettlementEntity,
SettingEntity,
StudentEntity
} from '../entities'
type exportBundle = {
students: StudentEntity[]
reasons: ReasonEntity[]
events: ScoreEventEntity[]
settlements: SettlementEntity[]
settings: SettingEntity[]
}
type importResult = { success: true } | { success: false; message: string }
export class DataBackupRepository {
constructor(private readonly dataSource: DataSource) {}
async exportJson(): Promise<string> {
const bundle = await this.exportBundle()
return JSON.stringify(bundle, null, 2)
}
private async exportBundle(): Promise<exportBundle> {
const students = await this.dataSource.getRepository(StudentEntity).find()
const reasons = await this.dataSource.getRepository(ReasonEntity).find()
const events = await this.dataSource.getRepository(ScoreEventEntity).find()
const settlements = await this.dataSource
.getRepository(SettlementEntity)
.find({ order: { id: 'ASC' } })
const settings = await this.dataSource
.getRepository(SettingEntity)
.createQueryBuilder('s')
.where("s.key NOT LIKE 'security_%'")
.getMany()
return { students, reasons, events, settlements, settings }
}
async importJson(jsonText: string): Promise<importResult> {
let parsed: any
try {
parsed = JSON.parse(String(jsonText ?? ''))
} catch {
return { success: false, message: 'Invalid JSON' }
}
const students = Array.isArray(parsed?.students) ? parsed.students : []
const reasons = Array.isArray(parsed?.reasons) ? parsed.reasons : []
const events = Array.isArray(parsed?.events) ? parsed.events : []
const settlements = Array.isArray(parsed?.settlements) ? parsed.settlements : []
const settings = Array.isArray(parsed?.settings) ? parsed.settings : []
try {
await this.dataSource.transaction(async (manager) => {
await manager.clear(ScoreEventEntity)
await manager.clear(SettlementEntity)
await manager.clear(StudentEntity)
await manager.clear(ReasonEntity)
await manager
.getRepository(SettingEntity)
.createQueryBuilder()
.delete()
.where("key NOT LIKE 'security_%'")
.execute()
const insertStudents: Partial<StudentEntity>[] = []
for (const s of students) {
const name = String(s?.name ?? '').trim()
if (!name) continue
const score = Number(s?.score ?? 0)
const extraJson = s?.extra_json != null ? String(s.extra_json) : null
const createdAt = s?.created_at != null ? String(s.created_at) : new Date().toISOString()
const updatedAt = s?.updated_at != null ? String(s.updated_at) : new Date().toISOString()
insertStudents.push({
name,
score: Number.isFinite(score) ? score : 0,
extra_json: extraJson,
created_at: createdAt,
updated_at: updatedAt
})
}
if (insertStudents.length) {
await manager.getRepository(StudentEntity).insert(insertStudents)
}
const insertReasons: Partial<ReasonEntity>[] = []
for (const r of reasons) {
const content = String(r?.content ?? '').trim()
if (!content) continue
const category = String(r?.category ?? '其他')
const delta = Number(r?.delta ?? 0)
const isSystem = Number(r?.is_system ?? 0) ? 1 : 0
const updatedAt = r?.updated_at != null ? String(r.updated_at) : new Date().toISOString()
insertReasons.push({
content,
category,
delta: Number.isFinite(delta) ? delta : 0,
is_system: isSystem,
updated_at: updatedAt
})
}
if (insertReasons.length) {
await manager.getRepository(ReasonEntity).insert(insertReasons)
}
const insertSettlements: Partial<SettlementEntity>[] = []
for (const s of settlements) {
const id = Number(s?.id)
const startTime = String(s?.start_time ?? '').trim()
const endTime = String(s?.end_time ?? '').trim()
const createdAt = String(s?.created_at ?? new Date().toISOString())
if (!Number.isFinite(id) || !startTime || !endTime) continue
insertSettlements.push({
id,
start_time: startTime,
end_time: endTime,
created_at: createdAt
})
}
if (insertSettlements.length) {
await manager.getRepository(SettlementEntity).insert(insertSettlements)
}
const insertEvents: Partial<ScoreEventEntity>[] = []
for (const e of events) {
const uuid = String(e?.uuid ?? '').trim()
const studentName = String(e?.student_name ?? '').trim()
const reasonContent = String(e?.reason_content ?? '').trim()
if (!uuid || !studentName || !reasonContent) continue
const delta = Number(e?.delta ?? 0)
const valPrev = Number(e?.val_prev ?? 0)
const valCurr = Number(e?.val_curr ?? 0)
const eventTime = String(e?.event_time ?? new Date().toISOString())
const settlementIdRaw = e?.settlement_id
const settlementId =
settlementIdRaw === null || settlementIdRaw === undefined
? null
: Number(settlementIdRaw)
insertEvents.push({
uuid,
student_name: studentName,
reason_content: reasonContent,
delta: Number.isFinite(delta) ? delta : 0,
val_prev: Number.isFinite(valPrev) ? valPrev : 0,
val_curr: Number.isFinite(valCurr) ? valCurr : 0,
event_time: eventTime,
settlement_id: Number.isFinite(settlementId as any) ? (settlementId as any) : null
})
}
if (insertEvents.length) {
await manager.getRepository(ScoreEventEntity).insert(insertEvents)
}
for (const it of settings) {
const key = String(it?.key ?? '').trim()
if (!key || key.startsWith('security_')) continue
await manager.getRepository(SettingEntity).save({ key, value: String(it?.value ?? '') })
}
})
} catch (e: any) {
return { success: false, message: e?.message || 'Import failed' }
}
return { success: true }
}
}
+5
View File
@@ -0,0 +1,5 @@
export { StudentEntity } from './entities/StudentEntity'
export { ReasonEntity } from './entities/ReasonEntity'
export { ScoreEventEntity } from './entities/ScoreEventEntity'
export { SettlementEntity } from './entities/SettlementEntity'
export { SettingEntity } from './entities/SettingEntity'
@@ -0,0 +1,23 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'reasons' })
export class ReasonEntity {
@PrimaryGeneratedColumn()
id!: number
@Index({ unique: true })
@Column({ type: 'text' })
content!: string
@Column({ type: 'text', default: '其他' })
category!: string
@Column({ type: 'integer' })
delta!: number
@Column({ type: 'integer', default: 0 })
is_system!: number
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
updated_at!: string
}
@@ -0,0 +1,33 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'score_events' })
export class ScoreEventEntity {
@PrimaryGeneratedColumn()
id!: number
@Index({ unique: true })
@Column({ type: 'text' })
uuid!: string
@Column({ type: 'text' })
student_name!: string
@Column({ type: 'text' })
reason_content!: string
@Column({ type: 'integer' })
delta!: number
@Column({ type: 'integer' })
val_prev!: number
@Column({ type: 'integer' })
val_curr!: number
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
event_time!: string
@Index()
@Column({ type: 'integer', nullable: true })
settlement_id!: number | null
}
@@ -0,0 +1,10 @@
import { Column, Entity, PrimaryColumn } from 'typeorm'
@Entity({ name: 'settings' })
export class SettingEntity {
@PrimaryColumn({ type: 'text' })
key!: string
@Column({ type: 'text', nullable: true })
value!: string | null
}
@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'settlements' })
export class SettlementEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ type: 'text' })
start_time!: string
@Column({ type: 'text' })
end_time!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
}
@@ -0,0 +1,34 @@
import { Column, Entity, PrimaryGeneratedColumn, ManyToMany, JoinTable } from 'typeorm'
import { TagEntity } from './TagEntity'
@Entity({ name: 'students' })
export class StudentEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ type: 'text' })
name!: string
@Column({ type: 'text', default: '[]' })
tags!: string
@Column({ type: 'integer', default: 0 })
score!: number
@Column({ type: 'text', nullable: true })
extra_json!: string | null
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
updated_at!: string
@ManyToMany(() => TagEntity, { cascade: true })
@JoinTable({
name: 'student_tags',
joinColumn: { name: 'student_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'tag_id', referencedColumnName: 'id' }
})
tagEntities!: TagEntity[]
}
@@ -0,0 +1,27 @@
import { Column, Entity, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Index } from 'typeorm'
import { StudentEntity } from './StudentEntity'
import { TagEntity } from './TagEntity'
@Entity({ name: 'student_tags' })
@Index(['student_id', 'tag_id'], { unique: true })
export class StudentTagEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ name: 'student_id', type: 'integer' })
student_id!: number
@Column({ name: 'tag_id', type: 'integer' })
tag_id!: number
@ManyToOne(() => StudentEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'student_id' })
student!: StudentEntity
@ManyToOne(() => TagEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'tag_id' })
tag!: TagEntity
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
}
+16
View File
@@ -0,0 +1,16 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'tags' })
export class TagEntity {
@PrimaryGeneratedColumn()
id!: number
@Column({ type: 'text', unique: true })
name!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
created_at!: string
@Column({ type: 'text', default: () => 'CURRENT_TIMESTAMP' })
updated_at!: string
}
+7
View File
@@ -0,0 +1,7 @@
export { StudentEntity } from './StudentEntity'
export { ReasonEntity } from './ReasonEntity'
export { ScoreEventEntity } from './ScoreEventEntity'
export { SettlementEntity } from './SettlementEntity'
export { SettingEntity } from './SettingEntity'
export { TagEntity } from './TagEntity'
export { StudentTagEntity } from './StudentTagEntity'
@@ -0,0 +1,155 @@
import type { MigrationInterface, QueryRunner } from 'typeorm'
export class InitSchema2026011800000 implements MigrationInterface {
name = 'InitSchema2026011800000'
private isPostgres(queryRunner: QueryRunner): boolean {
return queryRunner.connection.options.type === 'postgres'
}
async up(queryRunner: QueryRunner): Promise<void> {
const isPg = this.isPostgres(queryRunner)
const pkSerial = isPg ? 'SERIAL PRIMARY KEY' : 'integer PRIMARY KEY AUTOINCREMENT'
const now = isPg ? 'CURRENT_TIMESTAMP' : '(CURRENT_TIMESTAMP)'
const defaultZero = isPg ? 'DEFAULT 0' : 'DEFAULT (0)'
const defaultEmptyArr = isPg ? "DEFAULT '[]'" : "DEFAULT '[]'"
if (!(await queryRunner.hasTable('students'))) {
await queryRunner.query(`
CREATE TABLE "students" (
"id" ${pkSerial} NOT NULL,
"name" text NOT NULL,
"score" integer NOT NULL ${defaultZero},
"extra_json" text,
"tags" text NOT NULL ${defaultEmptyArr},
"created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT ${now}
)
`)
}
if (!(await queryRunner.hasTable('reasons'))) {
await queryRunner.query(`
CREATE TABLE "reasons" (
"id" ${pkSerial} NOT NULL,
"content" text NOT NULL,
"category" text NOT NULL DEFAULT '其他',
"delta" integer NOT NULL,
"is_system" integer NOT NULL ${defaultZero},
"updated_at" text NOT NULL DEFAULT ${now},
CONSTRAINT "UQ_reasons_content" UNIQUE ("content")
)
`)
}
if (!(await queryRunner.hasTable('settlements'))) {
await queryRunner.query(`
CREATE TABLE "settlements" (
"id" ${pkSerial} NOT NULL,
"start_time" text NOT NULL,
"end_time" text NOT NULL,
"created_at" text NOT NULL DEFAULT ${now}
)
`)
}
if (!(await queryRunner.hasTable('score_events'))) {
await queryRunner.query(`
CREATE TABLE "score_events" (
"id" ${pkSerial} NOT NULL,
"uuid" text NOT NULL,
"student_name" text NOT NULL,
"reason_content" text NOT NULL,
"delta" integer NOT NULL,
"val_prev" integer NOT NULL,
"val_curr" integer NOT NULL,
"event_time" text NOT NULL DEFAULT ${now},
"settlement_id" integer,
CONSTRAINT "UQ_score_events_uuid" UNIQUE ("uuid")
)
`)
}
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_score_events_settlement_id" ON "score_events" ("settlement_id")`
)
if (!(await queryRunner.hasTable('settings'))) {
await queryRunner.query(`
CREATE TABLE "settings" (
"key" text PRIMARY KEY NOT NULL,
"value" text
)
`)
}
const reasonValues = `('上课发言','课堂表现',1,1,CURRENT_TIMESTAMP),('作业优秀','作业情况',2,1,CURRENT_TIMESTAMP),('纪律良好','纪律',1,1,CURRENT_TIMESTAMP),('帮助同学','品德',2,1,CURRENT_TIMESTAMP),('迟到','纪律',-1,1,CURRENT_TIMESTAMP),('未交作业','作业情况',-2,1,CURRENT_TIMESTAMP)`
if (isPg) {
await queryRunner.query(`
INSERT INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
${reasonValues}
ON CONFLICT DO NOTHING
`)
} else {
await queryRunner.query(`
INSERT OR IGNORE INTO "reasons" ("content","category","delta","is_system","updated_at") VALUES
${reasonValues}
`)
}
if (!(await queryRunner.hasTable('tags'))) {
await queryRunner.query(`
CREATE TABLE "tags" (
"id" ${pkSerial} NOT NULL,
"name" text NOT NULL UNIQUE,
"created_at" text NOT NULL DEFAULT ${now},
"updated_at" text NOT NULL DEFAULT ${now}
)
`)
}
if (!(await queryRunner.hasTable('student_tags'))) {
await queryRunner.query(`
CREATE TABLE "student_tags" (
"id" ${pkSerial} NOT NULL,
"student_id" integer NOT NULL,
"tag_id" integer NOT NULL,
"created_at" text NOT NULL DEFAULT ${now},
FOREIGN KEY ("student_id") REFERENCES "students"("id") ON DELETE CASCADE,
FOREIGN KEY ("tag_id") REFERENCES "tags"("id") ON DELETE CASCADE,
UNIQUE("student_id", "tag_id")
)
`)
}
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_student_id" ON "student_tags" ("student_id")`
)
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_student_tags_tag_id" ON "student_tags" ("tag_id")`
)
const tagValues = `('优秀生'),('班干部'),('勤奋'),('活跃'),('进步快')`
if (isPg) {
await queryRunner.query(`
INSERT INTO "tags" ("name") VALUES ${tagValues}
ON CONFLICT DO NOTHING
`)
} else {
await queryRunner.query(`
INSERT OR IGNORE INTO "tags" ("name") VALUES ${tagValues}
`)
}
}
async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "score_events"`)
await queryRunner.query(`DROP TABLE IF EXISTS "settlements"`)
await queryRunner.query(`DROP TABLE IF EXISTS "students"`)
await queryRunner.query(`DROP TABLE IF EXISTS "reasons"`)
await queryRunner.query(`DROP TABLE IF EXISTS "settings"`)
await queryRunner.query(`DROP TABLE IF EXISTS "student_tags"`)
await queryRunner.query(`DROP TABLE IF EXISTS "tags"`)
}
}
+3
View File
@@ -0,0 +1,3 @@
import { InitSchema2026011800000 } from './InitSchema2026011800000'
export const migrations = [InitSchema2026011800000]