301 lines
13 KiB
TypeScript
301 lines
13 KiB
TypeScript
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}`)
|
||
}) |