diff --git a/backend/package-lock.json b/backend/package-lock.json index 1210f09..e877cc0 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@prisma/client": "^6.2.1", "debug": "^4.4.0", "dotenv": "^16.4.7", "koa": "^2.15.3", @@ -22,6 +23,7 @@ "@types/koa-route": "^3.2.8", "@types/node": "^14.0.0", "nodemon": "^3.1.9", + "prisma": "^6.2.1", "ts-node": "^10.0.0", "typedoc": "^0.27.6", "typescript": "^5.7.3" @@ -80,6 +82,68 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@prisma/client": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.2.1.tgz", + "integrity": "sha512-msKY2iRLISN8t5X0Tj7hU0UWet1u0KuxSPHWuf3IRkB4J95mCvGpyQBfQ6ufcmvKNOMQSq90O2iUmJEN2e5fiA==", + "hasInstallScript": true, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.2.1.tgz", + "integrity": "sha512-0KItvt39CmQxWkEw6oW+RQMD6RZ43SJWgEUnzxN8VC9ixMysa7MzZCZf22LCK5DSooiLNf8vM3LHZm/I/Ni7bQ==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.2.1.tgz", + "integrity": "sha512-lTBNLJBCxVT9iP5I7Mn6GlwqAxTpS5qMERrhebkUhtXpGVkBNd/jHnNJBZQW4kGDCKaQg/r2vlJYkzOHnAb7ZQ==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "6.2.1", + "@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69", + "@prisma/fetch-engine": "6.2.1", + "@prisma/get-platform": "6.2.1" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69.tgz", + "integrity": "sha512-7tw1qs/9GWSX6qbZs4He09TOTg1ff3gYsB3ubaVNN0Pp1zLm9NC5C5MZShtkz7TyQjx7blhpknB7HwEhlG+PrQ==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.2.1.tgz", + "integrity": "sha512-OO7O9d6Mrx2F9i+Gu1LW+DGXXyUFkP7OE5aj9iBfA/2jjDXEJjqa9X0ZmM9NZNo8Uo7ql6zKm6yjDcbAcRrw1A==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.2.1", + "@prisma/engines-version": "6.2.0-14.4123509d24aa4dede1e864b46351bf2790323b69", + "@prisma/get-platform": "6.2.1" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.2.1.tgz", + "integrity": "sha512-zp53yvroPl5m5/gXYLz7tGCNG33bhG+JYCm74ohxOq1pPnrL47VQYFfF3RbTZ7TzGWCrR3EtoiYMywUBw7UK6Q==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "6.2.1" + } + }, "node_modules/@shikijs/engine-oniguruma": { "version": "1.26.1", "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.26.1.tgz", @@ -1339,6 +1403,25 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prisma": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.2.1.tgz", + "integrity": "sha512-hhyM0H13pQleQ+br4CkzGizS5I0oInoeTw3JfLw1BRZduBSQxPILlJLwi+46wZzj9Je7ndyQEMGw/n5cN2fknA==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "6.2.1" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/backend/package.json b/backend/package.json index 30527e4..a61bca6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,6 +15,7 @@ "@types/koa-route": "^3.2.8", "@types/node": "^14.0.0", "nodemon": "^3.1.9", + "prisma": "^6.2.1", "ts-node": "^10.0.0", "typedoc": "^0.27.6", "typescript": "^5.7.3" @@ -23,6 +24,7 @@ "license": "ISC", "description": "", "dependencies": { + "@prisma/client": "^6.2.1", "debug": "^4.4.0", "dotenv": "^16.4.7", "koa": "^2.15.3", diff --git a/backend/prisma/migrations/20250111194859_init/migration.sql b/backend/prisma/migrations/20250111194859_init/migration.sql new file mode 100644 index 0000000..8d19f9e --- /dev/null +++ b/backend/prisma/migrations/20250111194859_init/migration.sql @@ -0,0 +1,47 @@ +-- CreateTable +CREATE TABLE `Payer` ( + `payer_id` INTEGER NOT NULL AUTO_INCREMENT, + `payer_name` VARCHAR(100) NOT NULL, + `payer_address` VARCHAR(255) NOT NULL, + `payer_abn` VARCHAR(15) NULL, + `payer_email` VARCHAR(100) NOT NULL, + + PRIMARY KEY (`payer_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `Invoice` ( + `payer_id` INTEGER NOT NULL, + `invoice_suffix_code` INTEGER NOT NULL, + `invoice_payer_name` VARCHAR(100) NOT NULL, + `invoice_payer_address` VARCHAR(255) NOT NULL, + `invoice_payer_abn` VARCHAR(15) NULL, + `invoice_billing_period_start` DATETIME(3) NOT NULL, + `invoice_billing_period_end` DATETIME(3) NOT NULL, + `invoice_due_date` DATETIME(3) NOT NULL, + `invoice_date` DATETIME(3) NOT NULL, + `invoice_total_amount` DECIMAL(10, 2) NOT NULL, + `invoice_note` TEXT NULL, + + PRIMARY KEY (`invoice_date`, `invoice_suffix_code`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `InvoiceItem` ( + `item_id` INTEGER NOT NULL, + `invoice_date` DATETIME(3) NOT NULL, + `invoice_suffix_code` INTEGER NOT NULL, + `item_description` VARCHAR(255) NOT NULL, + `item_quantity` INTEGER NOT NULL, + `item_unit_price` DECIMAL(10, 2) NOT NULL, + `item_unit` VARCHAR(50) NOT NULL, + `invoice_id` INTEGER NOT NULL, + + PRIMARY KEY (`item_id`, `invoice_date`, `invoice_suffix_code`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `Invoice` ADD CONSTRAINT `Invoice_payer_id_fkey` FOREIGN KEY (`payer_id`) REFERENCES `Payer`(`payer_id`) ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `InvoiceItem` ADD CONSTRAINT `InvoiceItem_invoice_date_invoice_suffix_code_fkey` FOREIGN KEY (`invoice_date`, `invoice_suffix_code`) REFERENCES `Invoice`(`invoice_date`, `invoice_suffix_code`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..8a21669 --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "mysql" \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..4c742d5 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,55 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DB_CONNECTION") +} + +model Payer { + payer_id Int @id @default(autoincrement()) + payer_name String @db.VarChar(100) + payer_address String @db.VarChar(255) + payer_abn String? @db.VarChar(15) + payer_email String @db.VarChar(100) + invoices Invoice[] +} + +model Invoice { + payer_id Int + invoice_suffix_code Int + invoice_payer_name String @db.VarChar(100) + invoice_payer_address String @db.VarChar(255) + invoice_payer_abn String? @db.VarChar(15) + invoice_billing_period_start DateTime + invoice_billing_period_end DateTime + invoice_due_date DateTime + invoice_date DateTime + invoice_total_amount Decimal @db.Decimal(10, 2) + invoice_note String? @db.Text + payer Payer @relation(fields: [payer_id], references: [payer_id]) + invoice_items InvoiceItem[] + + @@id([invoice_date, invoice_suffix_code]) +} + +model InvoiceItem { + item_id Int + invoice_date DateTime @map("invoice_date") + invoice_suffix_code Int @map("invoice_suffix_code") + item_description String @db.VarChar(255) + item_quantity Int + item_unit_price Decimal @db.Decimal(10, 2) + item_unit String @db.VarChar(50) + invoice_id Int + invoice Invoice @relation(fields: [invoice_date, invoice_suffix_code], references: [invoice_date, invoice_suffix_code]) + + @@id([item_id, invoice_date, invoice_suffix_code]) +} \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index dc88a85..85a3902 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,7 @@ import route from 'koa-route' import Debug from 'debug' import koaBody from 'koa-body' import func from './func' +import { ErrorDescEnum, HttpError } from './types/HttpError' dotenv.config() console.log = Debug('invoiceIssuer:app.ts') @@ -11,32 +12,50 @@ console.log = Debug('invoiceIssuer:app.ts') const app = new Koa() app.use(koaBody()) +// 错误处理中间件 +app.use(async (ctx, next) => { + try { + await next() + } catch (e) { + if (e instanceof HttpError) { + ctx.status = e.status + ctx.body = JSON.stringify({ error: e.message }) + } else { + console.log(e) + ctx.status = 500 + ctx.body = JSON.stringify({ error: ErrorDescEnum.unknown_issues }) + } + } +}) + app.use(route.get('/', (ctx) => { ctx.body = 'Hello World' })) app.use(route.post('/payer', async (ctx) => { // TODO: 请求头验证 bearer token - + + // 验证必填字段 + // 字段缺失时 + if (!ctx.request.body) throw new HttpError(ErrorDescEnum.required_fields_missing, 400) + + // 有字段,但为空时 const { name, address, email, abn } = ctx.request.body - if (!name || !address || !email) ctx.throw(400, 'required_fields_missing') + if (!name || !address || !email) throw new HttpError(ErrorDescEnum.required_fields_missing, 400) + + // 验证地址格式,格式应为 string[],最多两行 + if (!Array.isArray(address) || address.length > 2 || address.length <= 0) throw new HttpError(ErrorDescEnum.invalid_field_format, 400) + + // 名字应为字符串 + if (typeof name !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400) // 验证邮箱格式 const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/ - if (!emailRegex.test(email)) ctx.throw(400, 'invalid_field_format') + if (!emailRegex.test(email)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400) - try { - const payer = await func.createPayer(name, address, email, abn) - ctx.body = payer - } catch (e) { - if (e instanceof HttpError) { - ctx.status = e.status - ctx.body = e.message - } else { - console.error(e) - ctx.throw(500, 'unknown_issues') - } - } + // 创建新的付款人 + await func.createPayer(name, address, email, abn) + ctx.status = 204 })) const port = parseInt(process.env.PORT ?? '3000') diff --git a/backend/src/func/createPayer.ts b/backend/src/func/createPayer.ts index f3f0932..796c1cc 100644 --- a/backend/src/func/createPayer.ts +++ b/backend/src/func/createPayer.ts @@ -1,3 +1,8 @@ +import Debug from 'debug' +import { PrismaClient } from '@prisma/client' + +console.log = Debug('invoiceIssuer:func/createPayer.ts') + /** * POST /payer * @summary 创建一个新的付款人。这个付款人与收据上标识的收款人信息分离,但内部数据库关联(以便检索)。 @@ -7,4 +12,23 @@ * @param {string} abn - 付款人的澳大利亚商业号码 */ export default async (name: string, address: string[], email: string, abn?: string) => { + const prisma = new PrismaClient() + + try { + // 创建新的付款人 + const payer = await prisma.payer.create({ + data: { + payer_name: name, + payer_address: address.join('\n'), + payer_email: email, + payer_abn: abn + } + }) + return payer + } catch (e) { + console.log(e) + throw new Error('unknown_issues') + } finally { + await prisma.$disconnect() + } } \ No newline at end of file diff --git a/backend/src/types/HttpError.d.ts b/backend/src/types/HttpError.ts similarity index 81% rename from backend/src/types/HttpError.d.ts rename to backend/src/types/HttpError.ts index 72ff10d..c767f44 100644 --- a/backend/src/types/HttpError.d.ts +++ b/backend/src/types/HttpError.ts @@ -8,7 +8,14 @@ class HttpError extends Error { * @param message 错误的消息。 * @param status 错误的状态码。 */ - constructor(message: ErrorDescEnum, status: number); + constructor(message: ErrorDescEnum, status: 400 | 401 | 403 | 404 | 500) { + super(message); + this.status = status; + } + + /** + * 错误的状态码。 + */ status: 400 | 401 | 403 | 404 | 500; } @@ -28,4 +35,6 @@ enum ErrorDescEnum { * 未知错误。通常与 HTTP 状态码 500 Internal Server Error 一起使用。 */ unknown_issues = 'unknown_issues' -} \ No newline at end of file +} + +export { HttpError, ErrorDescEnum } \ No newline at end of file