feat: 新增多模块功能与服务实现
新增广告计划、用户资产、B2B交易、合规规则等核心模型 实现爬虫工作器、贸易服务、现金流预测等业务服务 添加RBAC权限测试、压力测试等测试用例 完善扩展程序的消息处理与内容脚本功能 重构应用入口与文档生成器 更新项目规则与业务闭环分析文档
This commit is contained in:
514
server/src/services/CertificateDatabaseService.ts
Normal file
514
server/src/services/CertificateDatabaseService.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import db from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
Certificate,
|
||||
CertificateCreateInput,
|
||||
CertificateUpdateInput,
|
||||
CertificateQueryOptions,
|
||||
CertificateType,
|
||||
CertificateStatus,
|
||||
} from '../models/Certificate';
|
||||
|
||||
export interface CertificateDatabaseRecord {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
shopId: string;
|
||||
taskId?: string;
|
||||
traceId: string;
|
||||
businessType: 'TOC' | 'TOB';
|
||||
certificateName: string;
|
||||
certificateType: CertificateType;
|
||||
certificateNo: string;
|
||||
productId?: string;
|
||||
productName?: string;
|
||||
issuer: string;
|
||||
issueDate: Date;
|
||||
expiryDate: Date;
|
||||
status: CertificateStatus;
|
||||
fileUrl?: string;
|
||||
fileHash?: string;
|
||||
remarks?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CertificateBackupData {
|
||||
version: string;
|
||||
exportedAt: Date;
|
||||
tenantId: string;
|
||||
totalRecords: number;
|
||||
certificates: CertificateDatabaseRecord[];
|
||||
}
|
||||
|
||||
export interface CertificateMigrationResult {
|
||||
success: boolean;
|
||||
totalMigrated: number;
|
||||
failedRecords: string[];
|
||||
errors: string[];
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface CertificateStatistics {
|
||||
totalCertificates: number;
|
||||
byStatus: Record<CertificateStatus, number>;
|
||||
byType: Record<CertificateType, number>;
|
||||
expiringWithin30Days: number;
|
||||
expired: number;
|
||||
byPlatform: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface BulkImportResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ row: number; error: string }>;
|
||||
}
|
||||
|
||||
export class CertificateDatabaseService {
|
||||
private static readonly TABLE_NAME = 'cf_certificate';
|
||||
private static readonly BACKUP_TABLE = 'cf_certificate_backup';
|
||||
private static readonly VERSION = '1.0.0';
|
||||
|
||||
static async initTable(): Promise<void> {
|
||||
const hasTable = await db.schema.hasTable(this.TABLE_NAME);
|
||||
if (!hasTable) {
|
||||
logger.info(`[CertificateDatabaseService] Creating ${this.TABLE_NAME} table...`);
|
||||
await db.schema.createTable(this.TABLE_NAME, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 64).notNullable().index();
|
||||
table.string('shop_id', 64).notNullable().index();
|
||||
table.string('task_id', 36);
|
||||
table.string('trace_id', 64).notNullable();
|
||||
table.enum('business_type', ['TOC', 'TOB']).notNullable();
|
||||
table.string('certificate_name', 255).notNullable();
|
||||
table.enum('certificate_type', [
|
||||
'CE', 'FCC', 'ROHS', 'ISO9001', 'ISO14001',
|
||||
'FDA', 'UL', 'CCC', 'EXPORT_LICENSE', 'ORIGIN', 'OTHER'
|
||||
]).notNullable();
|
||||
table.string('certificate_no', 128).notNullable();
|
||||
table.string('product_id', 64);
|
||||
table.string('product_name', 255);
|
||||
table.string('issuer', 255).notNullable();
|
||||
table.date('issue_date').notNullable();
|
||||
table.date('expiry_date').notNullable().index();
|
||||
table.enum('status', [
|
||||
'PENDING_REVIEW', 'APPROVED', 'REJECTED', 'EXPIRED', 'REVOKED'
|
||||
]).defaultTo('PENDING_REVIEW').notNullable();
|
||||
table.string('file_url', 512);
|
||||
table.string('file_hash', 128);
|
||||
table.text('remarks');
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||
|
||||
table.unique(['tenant_id', 'certificate_no'], 'uk_cert_no');
|
||||
table.index(['certificate_type'], 'idx_cert_type');
|
||||
table.index(['status'], 'idx_cert_status');
|
||||
table.index(['product_id'], 'idx_cert_product');
|
||||
});
|
||||
logger.info(`[CertificateDatabaseService] Table ${this.TABLE_NAME} created`);
|
||||
}
|
||||
|
||||
const hasBackupTable = await db.schema.hasTable(this.BACKUP_TABLE);
|
||||
if (!hasBackupTable) {
|
||||
await db.schema.createTable(this.BACKUP_TABLE, (table) => {
|
||||
table.string('id', 36).primary();
|
||||
table.string('tenant_id', 64).notNullable().index();
|
||||
table.json('backup_data').notNullable();
|
||||
table.string('version', 32).notNullable();
|
||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||
});
|
||||
logger.info(`[CertificateDatabaseService] Backup table ${this.BACKUP_TABLE} created`);
|
||||
}
|
||||
}
|
||||
|
||||
static async insert(record: CertificateDatabaseRecord): Promise<string> {
|
||||
const id = record.id || this.generateId();
|
||||
|
||||
await db(this.TABLE_NAME).insert({
|
||||
id,
|
||||
tenant_id: record.tenantId,
|
||||
shop_id: record.shopId,
|
||||
task_id: record.taskId || null,
|
||||
trace_id: record.traceId,
|
||||
business_type: record.businessType,
|
||||
certificate_name: record.certificateName,
|
||||
certificate_type: record.certificateType,
|
||||
certificate_no: record.certificateNo,
|
||||
product_id: record.productId || null,
|
||||
product_name: record.productName || null,
|
||||
issuer: record.issuer,
|
||||
issue_date: record.issueDate,
|
||||
expiry_date: record.expiryDate,
|
||||
status: record.status,
|
||||
file_url: record.fileUrl || null,
|
||||
file_hash: record.fileHash || null,
|
||||
remarks: record.remarks || null,
|
||||
});
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Certificate inserted: id=${id}, tenantId=${record.tenantId}, traceId=${record.traceId}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
static async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
updates: Partial<CertificateDatabaseRecord>,
|
||||
traceId: string
|
||||
): Promise<boolean> {
|
||||
const updateData: Record<string, any> = {
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
if (updates.certificateName) updateData.certificate_name = updates.certificateName;
|
||||
if (updates.certificateType) updateData.certificate_type = updates.certificateType;
|
||||
if (updates.certificateNo) updateData.certificate_no = updates.certificateNo;
|
||||
if (updates.productId !== undefined) updateData.product_id = updates.productId || null;
|
||||
if (updates.productName !== undefined) updateData.product_name = updates.productName || null;
|
||||
if (updates.issuer) updateData.issuer = updates.issuer;
|
||||
if (updates.issueDate) updateData.issue_date = updates.issueDate;
|
||||
if (updates.expiryDate) updateData.expiry_date = updates.expiryDate;
|
||||
if (updates.status) updateData.status = updates.status;
|
||||
if (updates.fileUrl !== undefined) updateData.file_url = updates.fileUrl || null;
|
||||
if (updates.fileHash !== undefined) updateData.file_hash = updates.fileHash || null;
|
||||
if (updates.remarks !== undefined) updateData.remarks = updates.remarks || null;
|
||||
|
||||
const result = await db(this.TABLE_NAME)
|
||||
.where({ id, tenant_id: tenantId })
|
||||
.update(updateData);
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Certificate updated: id=${id}, tenantId=${tenantId}, traceId=${traceId}`);
|
||||
return result > 0;
|
||||
}
|
||||
|
||||
static async delete(tenantId: string, id: string, traceId: string): Promise<boolean> {
|
||||
const result = await db(this.TABLE_NAME)
|
||||
.where({ id, tenant_id: tenantId })
|
||||
.delete();
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Certificate deleted: id=${id}, tenantId=${tenantId}, traceId=${traceId}`);
|
||||
return result > 0;
|
||||
}
|
||||
|
||||
static async getById(tenantId: string, id: string): Promise<CertificateDatabaseRecord | null> {
|
||||
const row = await db(this.TABLE_NAME)
|
||||
.where({ id, tenant_id: tenantId })
|
||||
.first();
|
||||
|
||||
return row ? this.mapRowToRecord(row) : null;
|
||||
}
|
||||
|
||||
static async query(options: CertificateQueryOptions): Promise<CertificateDatabaseRecord[]> {
|
||||
let query = db(this.TABLE_NAME).where('tenant_id', options.tenantId);
|
||||
|
||||
if (options.shopId) {
|
||||
query = query.andWhere('shop_id', options.shopId);
|
||||
}
|
||||
if (options.certificateType) {
|
||||
query = query.andWhere('certificate_type', options.certificateType);
|
||||
}
|
||||
if (options.status) {
|
||||
query = query.andWhere('status', options.status);
|
||||
}
|
||||
if (options.productId) {
|
||||
query = query.andWhere('product_id', options.productId);
|
||||
}
|
||||
if (options.searchKeyword) {
|
||||
query = query.andWhere(function() {
|
||||
this.where('certificate_name', 'like', `%${options.searchKeyword}%`)
|
||||
.orWhere('certificate_no', 'like', `%${options.searchKeyword}%`)
|
||||
.orWhere('product_name', 'like', `%${options.searchKeyword}%`);
|
||||
});
|
||||
}
|
||||
if (options.expiryDateFrom) {
|
||||
query = query.andWhere('expiry_date', '>=', options.expiryDateFrom);
|
||||
}
|
||||
if (options.expiryDateTo) {
|
||||
query = query.andWhere('expiry_date', '<=', options.expiryDateTo);
|
||||
}
|
||||
|
||||
const page = options.page || 1;
|
||||
const pageSize = options.pageSize || 20;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
query = query.orderBy('created_at', 'desc').limit(pageSize).offset(offset);
|
||||
|
||||
const rows = await query;
|
||||
return rows.map(this.mapRowToRecord);
|
||||
}
|
||||
|
||||
static async count(options: CertificateQueryOptions): Promise<number> {
|
||||
let query = db(this.TABLE_NAME).where('tenant_id', options.tenantId);
|
||||
|
||||
if (options.shopId) {
|
||||
query = query.andWhere('shop_id', options.shopId);
|
||||
}
|
||||
if (options.certificateType) {
|
||||
query = query.andWhere('certificate_type', options.certificateType);
|
||||
}
|
||||
if (options.status) {
|
||||
query = query.andWhere('status', options.status);
|
||||
}
|
||||
if (options.productId) {
|
||||
query = query.andWhere('product_id', options.productId);
|
||||
}
|
||||
|
||||
const result = await query.count('* as count').first();
|
||||
return Number(result?.count || 0);
|
||||
}
|
||||
|
||||
static async getStatistics(tenantId: string, shopId?: string): Promise<CertificateStatistics> {
|
||||
let query = db(this.TABLE_NAME).where('tenant_id', tenantId);
|
||||
if (shopId) {
|
||||
query = query.andWhere('shop_id', shopId);
|
||||
}
|
||||
|
||||
const records = await query;
|
||||
|
||||
const stats: CertificateStatistics = {
|
||||
totalCertificates: records.length,
|
||||
byStatus: {
|
||||
PENDING_REVIEW: 0,
|
||||
APPROVED: 0,
|
||||
REJECTED: 0,
|
||||
EXPIRED: 0,
|
||||
REVOKED: 0,
|
||||
},
|
||||
byType: {
|
||||
CE: 0,
|
||||
FCC: 0,
|
||||
ROHS: 0,
|
||||
ISO9001: 0,
|
||||
ISO14001: 0,
|
||||
FDA: 0,
|
||||
UL: 0,
|
||||
CCC: 0,
|
||||
EXPORT_LICENSE: 0,
|
||||
ORIGIN: 0,
|
||||
OTHER: 0,
|
||||
},
|
||||
expiringWithin30Days: 0,
|
||||
expired: 0,
|
||||
byPlatform: {},
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const thirtyDaysLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const record of records) {
|
||||
stats.byStatus[record.status as CertificateStatus]++;
|
||||
stats.byType[record.certificate_type as CertificateType]++;
|
||||
|
||||
const expiryDate = new Date(record.expiry_date);
|
||||
if (expiryDate < now) {
|
||||
stats.expired++;
|
||||
} else if (expiryDate <= thirtyDaysLater) {
|
||||
stats.expiringWithin30Days++;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
static async backup(tenantId: string, traceId: string): Promise<string> {
|
||||
const records = await db(this.TABLE_NAME).where('tenant_id', tenantId);
|
||||
|
||||
const backupData: CertificateBackupData = {
|
||||
version: this.VERSION,
|
||||
exportedAt: new Date(),
|
||||
tenantId,
|
||||
totalRecords: records.length,
|
||||
certificates: records.map(this.mapRowToRecord),
|
||||
};
|
||||
|
||||
const backupId = this.generateBackupId();
|
||||
|
||||
await db(this.BACKUP_TABLE).insert({
|
||||
id: backupId,
|
||||
tenant_id: tenantId,
|
||||
backup_data: JSON.stringify(backupData),
|
||||
version: this.VERSION,
|
||||
});
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Backup created: backupId=${backupId}, tenantId=${tenantId}, records=${records.length}, traceId=${traceId}`);
|
||||
return backupId;
|
||||
}
|
||||
|
||||
static async restore(
|
||||
tenantId: string,
|
||||
backupId: string,
|
||||
traceId: string
|
||||
): Promise<CertificateMigrationResult> {
|
||||
const result: CertificateMigrationResult = {
|
||||
success: false,
|
||||
totalMigrated: 0,
|
||||
failedRecords: [],
|
||||
errors: [],
|
||||
traceId,
|
||||
};
|
||||
|
||||
try {
|
||||
const backup = await db(this.BACKUP_TABLE)
|
||||
.where({ id: backupId, tenant_id: tenantId })
|
||||
.first();
|
||||
|
||||
if (!backup) {
|
||||
result.errors.push('Backup not found');
|
||||
return result;
|
||||
}
|
||||
|
||||
const backupData: CertificateBackupData = JSON.parse(backup.backup_data);
|
||||
|
||||
for (const cert of backupData.certificates) {
|
||||
try {
|
||||
const existing = await this.getById(tenantId, cert.id);
|
||||
|
||||
if (existing) {
|
||||
await this.update(tenantId, cert.id, cert, traceId);
|
||||
} else {
|
||||
await this.insert(cert);
|
||||
}
|
||||
|
||||
result.totalMigrated++;
|
||||
} catch (error: any) {
|
||||
result.failedRecords.push(cert.id);
|
||||
result.errors.push(`Failed to restore certificate ${cert.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
result.success = result.failedRecords.length === 0;
|
||||
logger.info(`[CertificateDatabaseService] Restore completed: backupId=${backupId}, migrated=${result.totalMigrated}, traceId=${traceId}`);
|
||||
} catch (error: any) {
|
||||
result.errors.push(`Restore failed: ${error.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async exportToJson(tenantId: string, traceId: string): Promise<CertificateBackupData> {
|
||||
const records = await db(this.TABLE_NAME).where('tenant_id', tenantId);
|
||||
|
||||
const exportData: CertificateBackupData = {
|
||||
version: this.VERSION,
|
||||
exportedAt: new Date(),
|
||||
tenantId,
|
||||
totalRecords: records.length,
|
||||
certificates: records.map(this.mapRowToRecord),
|
||||
};
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Export completed: tenantId=${tenantId}, records=${records.length}, traceId=${traceId}`);
|
||||
return exportData;
|
||||
}
|
||||
|
||||
static async bulkImport(
|
||||
tenantId: string,
|
||||
certificates: CertificateDatabaseRecord[],
|
||||
traceId: string
|
||||
): Promise<BulkImportResult> {
|
||||
const result: BulkImportResult = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
for (let i = 0; i < certificates.length; i++) {
|
||||
const cert = certificates[i];
|
||||
try {
|
||||
cert.tenantId = tenantId;
|
||||
await this.insert(cert);
|
||||
result.success++;
|
||||
} catch (error: any) {
|
||||
result.failed++;
|
||||
result.errors.push({ row: i + 1, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Bulk import completed: success=${result.success}, failed=${result.failed}, traceId=${traceId}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
static async getExpiringCertificates(
|
||||
tenantId: string,
|
||||
days: number,
|
||||
traceId: string
|
||||
): Promise<CertificateDatabaseRecord[]> {
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const records = await db(this.TABLE_NAME)
|
||||
.where('tenant_id', tenantId)
|
||||
.where('status', 'APPROVED')
|
||||
.where('expiry_date', '>=', now)
|
||||
.where('expiry_date', '<=', futureDate)
|
||||
.orderBy('expiry_date', 'asc');
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Found ${records.length} expiring certificates within ${days} days, traceId=${traceId}`);
|
||||
return records.map(this.mapRowToRecord);
|
||||
}
|
||||
|
||||
static async updateExpiredStatus(tenantId: string, traceId: string): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
const result = await db(this.TABLE_NAME)
|
||||
.where('tenant_id', tenantId)
|
||||
.where('status', 'APPROVED')
|
||||
.where('expiry_date', '<', now)
|
||||
.update({ status: 'EXPIRED', updated_at: now });
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Updated ${result} certificates to EXPIRED status, tenantId=${tenantId}, traceId=${traceId}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
static async getBackups(tenantId: string): Promise<Array<{ id: string; version: string; createdAt: Date }>> {
|
||||
const backups = await db(this.BACKUP_TABLE)
|
||||
.where('tenant_id', tenantId)
|
||||
.orderBy('created_at', 'desc')
|
||||
.select('id', 'version', 'created_at');
|
||||
|
||||
return backups.map(b => ({
|
||||
id: b.id,
|
||||
version: b.version,
|
||||
createdAt: b.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
static async deleteBackup(tenantId: string, backupId: string, traceId: string): Promise<boolean> {
|
||||
const result = await db(this.BACKUP_TABLE)
|
||||
.where({ id: backupId, tenant_id: tenantId })
|
||||
.delete();
|
||||
|
||||
logger.info(`[CertificateDatabaseService] Backup deleted: backupId=${backupId}, tenantId=${tenantId}, traceId=${traceId}`);
|
||||
return result > 0;
|
||||
}
|
||||
|
||||
private static generateId(): string {
|
||||
return `cert_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private static generateBackupId(): string {
|
||||
return `backup_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private static mapRowToRecord(row: Record<string, any>): CertificateDatabaseRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId: row.tenant_id,
|
||||
shopId: row.shop_id,
|
||||
taskId: row.task_id,
|
||||
traceId: row.trace_id,
|
||||
businessType: row.business_type,
|
||||
certificateName: row.certificate_name,
|
||||
certificateType: row.certificate_type,
|
||||
certificateNo: row.certificate_no,
|
||||
productId: row.product_id,
|
||||
productName: row.product_name,
|
||||
issuer: row.issuer,
|
||||
issueDate: row.issue_date,
|
||||
expiryDate: row.expiry_date,
|
||||
status: row.status,
|
||||
fileUrl: row.file_url,
|
||||
fileHash: row.file_hash,
|
||||
remarks: row.remarks,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user