invoice-issuer/backend/src/app.ts

301 lines
13 KiB
TypeScript
Raw 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 Koa from 'koa'
import dotenv from 'dotenv'
import route from 'koa-route'
import Debug from 'debug'
import koaBody from 'koa-body'
import func from './func'
import { ErrorDescEnum, HttpError } from './classes/HttpError'
import { PrismaClient } from '@prisma/client'
import InvoiceItem from './classes/InvoiceItem'
dotenv.config()
console.log = Debug('invoiceIssuer:app.ts')
const app = new Koa()
app.use(koaBody())
// 语境中间件
app.use(async (ctx, next) => {
try {
const prismaClient = new PrismaClient()
ctx.prisma = prismaClient
await next()
prismaClient.$disconnect()
} catch (e) {
if (e instanceof HttpError) {
ctx.status = e.status
let errorBody: { error: string, context?: string[] } = { error: e.message }
if (e.context) errorBody['context'] = e.context
ctx.body = JSON.stringify(errorBody)
} 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) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 验证必填字段
// 字段缺失时
if (!ctx.request.body) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, ['name', 'address', 'email'])
// 有字段,但为空时
const { name, address, email, abn } = ctx.request.body
let emptyFields = []
if (!name) emptyFields.push('name')
if (!address) emptyFields.push('address')
if (!email) emptyFields.push('email')
if (emptyFields.length > 0) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, emptyFields)
// 验证地址格式,格式应为 string[],最多两行
if (!Array.isArray(address) || address.length > 2 || address.length <= 0) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['address'])
// 名字应为字符串
if (typeof name !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['name'])
// 验证邮箱格式
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(email)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['email'])
// 验证 ABN 格式
const abnRegex = /^\d{11}$/
if (abn && !abnRegex.test(abn)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['abn'])
// 创建新的付款人
await func.createPayer(ctx.prisma, name, address, email, abn)
ctx.status = 204
}))
app.use(route.get('/payer', async (ctx) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 获取 pager 和 limit
const pageQuery = ctx.request.query.page
if (Array.isArray(pageQuery)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['page'])
const page = pageQuery ? parseInt(pageQuery) : 1
const limitQuery = ctx.request.query.limit
if (Array.isArray(limitQuery)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['limit'])
const limit = limitQuery ? parseInt(limitQuery) : 10
if (page <= 0 || limit < 5) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['page', 'limit'])
// 获取付款人
const payers = await func.getPayers(ctx.prisma, page, limit)
ctx.body = payers
}))
app.use(route.put('/payer/:id', async (ctx, id) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 验证 id 必须是数字
if (isNaN(parseInt(id))) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['id'])
// 验证必填字段
// 字段缺失时
if (!ctx.request.body) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, ['name', 'address', 'email'])
// 有字段,但为空时
const { name, address, email, abn } = ctx.request.body
let emptyFields = []
if (!name) emptyFields.push('name')
if (!address) emptyFields.push('address')
if (!email) emptyFields.push('email')
if (emptyFields.length > 0) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, emptyFields)
// 验证地址格式,格式应为 string[],最多两行
if (!Array.isArray(address) || address.length > 2 || address.length <= 0) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['address'])
// 名字应为字符串
if (typeof name !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['name'])
// 验证邮箱格式
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
if (!emailRegex.test(email)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['email'])
// 验证 ABN 格式
const abnRegex = /^\d{11}$/
if (abn && !abnRegex.test(abn)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['abn'])
// 更新付款人
await func.updatePayer(ctx.prisma, id, name, address, email, abn)
ctx.status = 204
}))
app.use(route.delete('/payer/:id', async (ctx, id) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
if (isNaN(parseInt(id))) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['id'])
// 删除付款人
await func.deletePayer(ctx.prisma, id)
ctx.status = 204
}))
app.use(route.post('/invoice', async (ctx) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 提取字段,并验证必填字段
if (!ctx.request.body) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, ['payerId', 'period', 'items', 'dueDate'])
const { payerId, period: periodRaw, items: itemsRaw, dueDate: dueDateRaw, note } = ctx.request.body
let emptyFields = []
if (!payerId) emptyFields.push('payerId')
if (!periodRaw) emptyFields.push('period')
if (!itemsRaw) emptyFields.push('items')
if (!dueDateRaw) emptyFields.push('dueDate')
if (emptyFields.length > 0) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, emptyFields)
// 验证 payerId 必须是数字
if (typeof payerId !== 'number') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['payerId'])
// 验证 period 必须是两个日期的数组
if (!Array.isArray(periodRaw) || periodRaw.length !== 2) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
// Period 中的日期格式为 YYYY-MM-DD且第一个日期早于第二个日期
const dateRegex = /^\d{4}-\d{2}-\d{2}$/
if (!dateRegex.test(periodRaw[0]) || !dateRegex.test(periodRaw[1])) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
if (new Date(periodRaw[0]) > new Date(periodRaw[1])) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['period'])
const period = [
new Date(periodRaw[0].split('-')[0], periodRaw[0].split('-')[1] - 1, periodRaw[0].split('-')[2]),
new Date(periodRaw[1].split('-')[0], periodRaw[1].split('-')[1] - 1, periodRaw[1].split('-')[2])
]
// 验证 items 必须是数组
if (!Array.isArray(itemsRaw)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['items'])
// 验证 items 中的每一项必须是指定格式的对象
// description: string, quantity: number, unit: string, unitPrice: Decimal
const itemFields = ['description', 'quantity', 'unit', 'unitPrice']
itemsRaw.forEach((item, index) => {
itemFields.forEach((field) => {
if (!item[field]) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, [`items[${index}].${field}`])
})
if (typeof item['description'] !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].description`])
if (typeof item['quantity'] !== 'number') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].quantity`])
if (typeof item['unit'] !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].unit`])
if (typeof item['unitPrice'] !== 'number') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, [`items[${index}].unitPrice`])
})
// 验证 dueDate 必须是日期格式
if (!dateRegex.test(dueDateRaw)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['dueDate'])
const dueDate = new Date(dueDateRaw.split('-')[0], dueDateRaw.split('-')[1] - 1, dueDateRaw.split('-')[2])
// 验证 note 必须是字符串
if (note && typeof note !== 'string') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['note'])
// 创建新的收据
const items = itemsRaw.map(item => {
return new InvoiceItem(0, item['description'], item['quantity'], item['unit'], item['unitPrice'])
})
await func.issueInvoice(ctx.prisma, payerId, period, items, dueDate, note)
ctx.status = 204
}))
app.use(route.get('/invoice/INV-:date(\\d{8})-:suffix(\\d+)', async (ctx, invoiceId, suffix) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 验证 invoiceId 格式
const date = invoiceId.slice(0, 4) + '-' + invoiceId.slice(4, 6) + '-' + invoiceId.slice(6, 8)
// 获取特定收据
const invoice = await func.getSpecificInvoice(ctx.prisma, date, parseInt(suffix))
console.log(invoice.exportJSON())
ctx.body = invoice.exportJSON()
}))
app.use(route.get('/invoice', async (ctx) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 获取 pager 和 limit
const pageQuery = ctx.request.query.page
if (Array.isArray(pageQuery)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['page'])
const page = pageQuery ? parseInt(pageQuery) : 1
const limitQuery = ctx.request.query.limit
if (Array.isArray(limitQuery)) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['limit'])
const limit = limitQuery ? parseInt(limitQuery) : 10
if (page <= 0 || limit < 5) throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['page', 'limit'])
// 获取收据
const invoices = await func.getInvoices(ctx.prisma, page, limit)
ctx.body = await Promise.all(invoices.map(async invoice => (await invoice).exportJSON()))
}))
app.use(route.put('/invoice/INV-:date(\\d{8})-:suffix(\\d+)/note', async (ctx, invoiceId, suffix) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 验证 invoiceId 格式
const date = invoiceId.slice(0, 4) + '-' + invoiceId.slice(4, 6) + '-' + invoiceId.slice(6, 8)
// 验证必填字段
if (!ctx.request.body) throw new HttpError(ErrorDescEnum.required_fields_missing, 400, ['note'])
console.log(typeof ctx.request.body.note)
if (typeof ctx.request.body.note !== 'string' && typeof ctx.request.body.note !== 'undefined') throw new HttpError(ErrorDescEnum.invalid_field_format, 400, ['note'])
// 更新收据备注
await func.updateInvoiceNote(ctx.prisma, date, suffix, ctx.request.body.note)
ctx.status = 204
}))
app.use(route.delete('/invoice/INV-:date(\\d{8})-:suffix(\\d+)', async (ctx, date, suffix) => {
// 请求头验证 bearer token
const bearerToken = ctx.request.headers['authorization']?.split(' ')
if (!bearerToken) throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (bearerToken[0] !== 'Bearer') throw new HttpError(ErrorDescEnum.unauthorized, 401)
if (!bearerToken[1]) throw new HttpError(ErrorDescEnum.unauthorized, 401)
await func.verifyBearerToken(bearerToken[1])
// 删除收据
await func.deleteInvoice(ctx.prisma, date, suffix)
ctx.status = 204
}))
const port = parseInt(process.env.PORT ?? '3000')
app.listen(port, () => {
console.log(`Server is running at http://localhost:${port}`)
})