通用开票/查询接口

This commit is contained in:
BBIT-Kai
2026-05-20 16:20:40 +08:00
parent cdcfaa192c
commit ccc164b176
24 changed files with 1247 additions and 30 deletions
+260
View File
@@ -0,0 +1,260 @@
[
{
"project_id": "-1",
"target_id": "open_blue_invoice_create",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票单笔开票",
"sort": 1,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "POST",
"url": "{{baseUrl}}/api/open/v1/blue-invoices",
"request": {
"auth": {
"type": "noauth",
"kv": { "key": "", "value": "", "in": "header" },
"bearer": { "key": "" },
"basic": { "username": "", "password": "" },
"noauth": {}
},
"body": {
"mode": "json",
"parameter": [],
"raw": "{\n \"requestNo\": \"REQ202605200001\",\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"invoiceIssueKindCode\": \"82\",\n \"buyerName\": \"测试购买方有限公司\",\n \"buyerTaxpayerNum\": \"91350000000000000X\",\n \"takerName\": \"张三\",\n \"takerTel\": \"13800000000\",\n \"takerEmail\": \"test@example.com\",\n \"remark\": \"开放API测试开票\",\n \"itemList\": [\n {\n \"goodsName\": \"信息技术服务\",\n \"taxClassificationCode\": \"3040201010000000000\",\n \"includeTaxFlag\": \"1\",\n \"quantity\": \"1\",\n \"unitPrice\": \"100.00\",\n \"invoiceAmount\": \"100.00\",\n \"taxRateValue\": \"0.06\"\n }\n ]\n}",
"raw_parameter": [],
"raw_schema": { "type": "object" },
"binary": null
},
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" },
{ "key": "Content-Type", "value": "application/json", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": { "parameter": [] },
"tabs_default_active_key": "body"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"requestNo\": \"REQ202605200001\",\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"status\": \"PROCESSING\"\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": {
"name": "成功",
"is_default": 1,
"code": "200",
"sleep": 0,
"content_type": "json",
"verify_type": "schema",
"mock": "",
"schema": { "type": "object", "properties": {} }
}
}
]
},
"description": "Header 必填:X-Api-Key。taxpayerNum/account 从绑定用户信息中读取,不需要在请求中传。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_query",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票单笔查询",
"sort": 2,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "GET",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/{{invoiceReqSerialNo}}",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": { "mode": "none", "parameter": [], "raw": "", "raw_parameter": [], "raw_schema": { "type": "object" }, "binary": null },
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": {
"parameter": [
{ "key": "invoiceReqSerialNo", "value": "OPEN202605200001", "is_checked": 1, "type": "string" }
]
},
"tabs_default_active_key": "restful"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"status\": \"SUCCESS\",\n \"code\": \"0000\",\n \"message\": \"开票成功\",\n \"invoiceCode\": null,\n \"invoiceNo\": \"12345678\",\n \"electronicInvoiceNo\": \"25100000000000000000\",\n \"invoiceDate\": \"2026-05-20 10:00:00\",\n \"buyerName\": \"测试购买方有限公司\",\n \"totalAmount\": \"100.00\",\n \"downloadUrl\": \"https://example.com/invoice.pdf\"\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "查询时会同步票通状态,然后返回平台标准化结果。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_sample",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票票样查询",
"sort": 3,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "GET",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/{{invoiceReqSerialNo}}/sample",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": { "mode": "none", "parameter": [], "raw": "", "raw_parameter": [], "raw_schema": { "type": "object" }, "binary": null },
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": {
"parameter": [
{ "key": "invoiceReqSerialNo", "value": "OPEN202605200001", "is_checked": 1, "type": "string" }
]
},
"tabs_default_active_key": "restful"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"downloadUrl\": \"https://example.com/invoice.pdf\"\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "返回已保存的 downloadUrl,不返回 base64 文件内容。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_batch_create",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票批量开票",
"sort": 4,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "POST",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/batches",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": {
"mode": "json",
"parameter": [],
"raw": "{\n \"batchNo\": \"BATCH202605200001\",\n \"items\": [\n {\n \"requestNo\": \"REQ202605200101\",\n \"invoiceReqSerialNo\": \"OPEN202605200101\",\n \"invoiceIssueKindCode\": \"82\",\n \"buyerName\": \"批量测试客户A\",\n \"buyerTaxpayerNum\": \"91350000000000000X\",\n \"remark\": \"开放API批量测试A\",\n \"itemList\": [\n {\n \"goodsName\": \"信息技术服务\",\n \"taxClassificationCode\": \"3040201010000000000\",\n \"includeTaxFlag\": \"1\",\n \"quantity\": \"1\",\n \"unitPrice\": \"50.00\",\n \"invoiceAmount\": \"50.00\",\n \"taxRateValue\": \"0.06\"\n }\n ]\n },\n {\n \"requestNo\": \"REQ202605200102\",\n \"invoiceReqSerialNo\": \"OPEN202605200102\",\n \"invoiceIssueKindCode\": \"82\",\n \"buyerName\": \"批量测试客户B\",\n \"remark\": \"开放API批量测试B\",\n \"itemList\": [\n {\n \"goodsName\": \"软件服务费\",\n \"taxClassificationCode\": \"3040201010000000000\",\n \"includeTaxFlag\": \"1\",\n \"quantity\": \"1\",\n \"unitPrice\": \"80.00\",\n \"invoiceAmount\": \"80.00\",\n \"taxRateValue\": \"0.06\"\n }\n ]\n }\n ]\n}",
"raw_parameter": [],
"raw_schema": { "type": "object" },
"binary": null
},
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" },
{ "key": "Content-Type", "value": "application/json", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": { "parameter": [] },
"tabs_default_active_key": "body"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"batchNo\": \"BATCH202605200001\",\n \"status\": \"PROCESSING\",\n \"total\": 2\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "批量接口提交后异步处理,使用批量结果查询接口查看进度。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_batch_query",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票批量结果查询",
"sort": 5,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "GET",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/batches/{{batchNo}}",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": { "mode": "none", "parameter": [], "raw": "", "raw_parameter": [], "raw_schema": { "type": "object" }, "binary": null },
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": {
"parameter": [
{ "key": "batchNo", "value": "BATCH202605200001", "is_checked": 1, "type": "string" }
]
},
"tabs_default_active_key": "restful"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"batchNo\": \"BATCH202605200001\",\n \"status\": \"PROCESSING\",\n \"total\": 2,\n \"success\": 1,\n \"failed\": 0,\n \"processing\": 1,\n \"items\": [\n {\n \"requestNo\": \"REQ202605200101\",\n \"invoiceReqSerialNo\": \"OPEN202605200101\",\n \"status\": \"SUCCESS\",\n \"errorCode\": null,\n \"errorMessage\": null\n },\n {\n \"requestNo\": \"REQ202605200102\",\n \"invoiceReqSerialNo\": \"OPEN202605200102\",\n \"status\": \"PROCESSING\",\n \"errorCode\": null,\n \"errorMessage\": null\n }\n ]\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "批次状态:PROCESSING / SUCCESS / FAILED / PARTIAL_FAILED。",
"tags": ["开放API", "蓝票"]
}
]
@@ -15,7 +15,8 @@ import com.bbit.ticket.utils.plugins.configureSerialization
import com.bbit.ticket.utils.plugins.configureStatusPages
import com.bbit.ticket.utils.plugins.configureTrace
import com.bbit.ticket.route.piaotong.registerPTAuthRoutes
import com.bbit.ticket.route.piaotong.registerPTiInvoiceRoutes
import com.bbit.ticket.route.piaotong.registerPTInvoiceRoutes
import com.bbit.ticket.route.openapi.registerOpenBlueInvoiceRoutes
import com.bbit.ticket.route.system.registerDictRoutes
import com.bbit.ticket.route.system.registerLogsQueryRoutes
import com.bbit.ticket.route.system.registerMenuRoutes
@@ -64,11 +65,18 @@ fun Application.module() {
registerMenuRoutes()
registerDictRoutes()
registerLogsQueryRoutes()
route("/open/v1") {
route("/blue-invoices") {
registerOpenBlueInvoiceRoutes()
}
route("/f8") {
}
}
route("/pt") {
authenticate("auth-jwt") {
registerPTAuthRoutes()
registerPTiInvoiceRoutes()
registerPTInvoiceRoutes()
}
}
}
@@ -7,6 +7,8 @@ import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.response.GetInvoiceInfoResponse
@@ -24,7 +26,9 @@ import org.jetbrains.exposed.v1.core.ResultRow
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.neq
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
import org.jetbrains.exposed.v1.jdbc.deleteWhere
@@ -41,6 +45,12 @@ import kotlin.uuid.Uuid
object BlueInvoiceDao {
fun listBatchNos(userId: Uuid): List<String> =
OpenInvoiceBatchTable.selectAll()
.where { OpenInvoiceBatchTable.userId eq userId }
.orderBy(OpenInvoiceBatchTable.createdAt, SortOrder.DESC)
.map { it[OpenInvoiceBatchTable.batchNo] }
// =============================================
// 开票历史查询
// =============================================
@@ -53,7 +63,8 @@ object BlueInvoiceDao {
page: Int,
pageSize: Int,
invoiceType: String? = null,
isSuccess: Boolean? = null
isSuccess: Boolean? = null,
batchNo: String? = null,
): PageResult<InvoiceHistoryItem> {
val conditions = mutableListOf<Op<Boolean>>()
conditions.add(HistoryInvoiceBasicTable.userId eq userId)
@@ -80,18 +91,57 @@ object BlueInvoiceDao {
}
}
val normalizedBatchNo = batchNo?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedBatchNo != null) {
val matchedSerialNos = (OpenInvoiceBatchItemTable innerJoin OpenInvoiceBatchTable)
.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq userId) and
(OpenInvoiceBatchTable.batchNo like "%$normalizedBatchNo%")
}
.map { it[OpenInvoiceBatchItemTable.invoiceReqSerialNo] }
.distinct()
if (matchedSerialNos.isEmpty()) {
return PageResult(emptyList(), page, pageSize, 0)
}
conditions.add(HistoryInvoiceBasicTable.invoiceReqSerialNo inList matchedSerialNos)
}
val whereClause = conditions.reduce { a, b -> a and b }
val total = HistoryInvoiceBasicTable.selectAll()
.where(whereClause)
.count()
val items = HistoryInvoiceBasicTable.selectAll()
val rows = HistoryInvoiceBasicTable.selectAll()
.where(whereClause)
.orderBy(HistoryInvoiceBasicTable.createdAt, SortOrder.DESC)
.limit(pageSize)
.offset(pageOffset(page, pageSize))
.map { it.toHistoryItem() }
val batchMap = rows
.map { it[HistoryInvoiceBasicTable.invoiceReqSerialNo] }
.distinct()
.takeIf { it.isNotEmpty() }
?.let { serialNos ->
(OpenInvoiceBatchItemTable innerJoin OpenInvoiceBatchTable)
.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq userId) and
(OpenInvoiceBatchItemTable.invoiceReqSerialNo inList serialNos)
}
.associate { row ->
row[OpenInvoiceBatchItemTable.invoiceReqSerialNo] to row[OpenInvoiceBatchTable.batchNo]
}
}
?: emptyMap()
val items = rows.map { row ->
val invoiceReqSerialNo = row[HistoryInvoiceBasicTable.invoiceReqSerialNo]
row.toHistoryItem(batchNo = batchMap[invoiceReqSerialNo])
}
return PageResult(items, page, pageSize, total)
}
@@ -215,6 +265,19 @@ object BlueInvoiceDao {
}
}
fun markInvoiceFailed(userId: Uuid, invoiceReqSerialNo: String, message: String?) {
val now = OffsetDateTime.now()
val errorMessage = (message ?: "开票失败").take(200)
HistoryInvoiceBasicTable.update({
(HistoryInvoiceBasicTable.userId eq userId) and
(HistoryInvoiceBasicTable.invoiceReqSerialNo eq invoiceReqSerialNo)
}) {
it[code] = "9999"
it[msg] = errorMessage
it[updatedAt] = now
}
}
fun <T> UpdateBuilder<Int>.setIfNotNull(
column: Column<T>,
value: T?
@@ -542,7 +605,7 @@ object BlueInvoiceDao {
/**
* 将 [ResultRow] 转换为 [InvoiceHistoryItem](列表页使用)
*/
private fun ResultRow.toHistoryItem(): InvoiceHistoryItem {
private fun ResultRow.toHistoryItem(batchNo: String? = null): InvoiceHistoryItem {
val codeValue = this[HistoryInvoiceBasicTable.code]
val status = when (codeValue) {
"0000" -> "SUCCESS"
@@ -564,6 +627,7 @@ object BlueInvoiceDao {
return InvoiceHistoryItem(
id = this[HistoryInvoiceBasicTable.id],
batchNo = batchNo,
invoiceReqSerialNo = this[HistoryInvoiceBasicTable.invoiceReqSerialNo],
taxpayerNum = this[HistoryInvoiceBasicTable.sellerTaxpayerNum],
invoiceKindCode = this[HistoryInvoiceBasicTable.invoiceKind],
@@ -26,6 +26,8 @@ import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
object LogDao {
private const val MAX_API_LOG_BODY_LENGTH = 20_000
fun operationLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<OperationLogItem> {
var where: Op<Boolean> = Op.TRUE
if (!keyword.isNullOrBlank()) {
@@ -125,4 +127,38 @@ object LogDao {
it[createdAt] = OffsetDateTime.now()
}
}
fun saveApiAccessLog(
call: ApplicationCall,
appKey: String?,
appName: String?,
requestBody: String?,
responseCode: String?,
responseBody: String?,
status: String,
errorMessage: String?,
costMs: Long,
) {
SysApiAccessLogTable.insert {
it[traceId] = call.traceIdOrNull()
it[SysApiAccessLogTable.appKey] = appKey?.take(100)
it[SysApiAccessLogTable.appName] = appName?.take(100)
it[httpMethod] = call.request.httpMethod.value
it[requestPath] = call.request.path().take(255)
it[requestHeaders] = null
it[SysApiAccessLogTable.requestBody] = requestBody.truncateApiBody()
it[SysApiAccessLogTable.responseCode] = responseCode
it[SysApiAccessLogTable.responseBody] = responseBody.truncateApiBody()
it[ip] = call.request.local.remoteHost.take(64)
it[SysApiAccessLogTable.status] = status
it[SysApiAccessLogTable.errorMessage] = errorMessage
it[SysApiAccessLogTable.costMs] = costMs
it[createdAt] = OffsetDateTime.now()
}
}
private fun String?.truncateApiBody(): String? {
if (this == null || length <= MAX_API_LOG_BODY_LENGTH) return this
return take(MAX_API_LOG_BODY_LENGTH) + "...[truncated, originalLength=$length]"
}
}
@@ -12,6 +12,7 @@ import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.utils.ApiKeyUtil
import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.ResultRow
@@ -80,6 +81,7 @@ object UserDao {
it[SysUserTable.avatar] = request.avatar.trimToNull()
it[SysUserTable.orgId] = orgId
it[SysUserTable.status] = request.status
it[SysUserTable.apiKey] = ApiKeyUtil.generate()
it[SysUserTable.tokenVersion] = 1
it[SysUserTable.createdAt] = now
}
@@ -221,6 +223,7 @@ object UserDao {
status = this[SysUserTable.status],
statusLabel = statusLabel(this[SysUserTable.status]),
roleCodes = roleCodes,
apiKey = this[SysUserTable.apiKey],
)
private fun ResultRow.toUserDetail(roleIds: List<String>) = UserDetailResponse(
@@ -235,6 +238,7 @@ object UserDao {
status = this[SysUserTable.status],
statusLabel = statusLabel(this[SysUserTable.status]),
roleIds = roleIds,
apiKey = this[SysUserTable.apiKey],
taxpayerNum = this[SysUserTable.taxpayerNum],
account = this[SysUserTable.taxAccount],
taxPassword = this[SysUserTable.taxPassword],
@@ -0,0 +1,47 @@
package com.bbit.ticket.database.piaotong
import org.jetbrains.exposed.v1.core.Table
import org.jetbrains.exposed.v1.javatime.timestampWithTimeZone
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
@OptIn(ExperimentalUuidApi::class)
object OpenInvoiceBatchTable : Table("open_invoice_batch") {
val id = uuid("id").clientDefault { Uuid.random() }
val userId = uuid("user_id")
val batchNo = varchar("batch_no", 64)
val totalCount = integer("total_count").default(0)
val successCount = integer("success_count").default(0)
val failedCount = integer("failed_count").default(0)
val processingCount = integer("processing_count").default(0)
val status = varchar("status", 20).default("PROCESSING")
val resolved = bool("resolved").default(false)
val createdAt = timestampWithTimeZone("created_at")
val updatedAt = timestampWithTimeZone("updated_at").nullable()
override val primaryKey = PrimaryKey(id)
init {
uniqueIndex(userId, batchNo)
}
}
@OptIn(ExperimentalUuidApi::class)
object OpenInvoiceBatchItemTable : Table("open_invoice_batch_item") {
val id = uuid("id").clientDefault { Uuid.random() }
val batchId = uuid("batch_id").references(OpenInvoiceBatchTable.id)
val requestNo = varchar("request_no", 64).nullable()
val invoiceReqSerialNo = varchar("invoice_req_serial_no", 64)
val originalRequestBody = text("original_request_body").nullable()
val status = varchar("status", 20).default("PENDING")
val errorCode = varchar("error_code", 64).nullable()
val errorMessage = text("error_message").nullable()
val createdAt = timestampWithTimeZone("created_at")
val updatedAt = timestampWithTimeZone("updated_at").nullable()
override val primaryKey = PrimaryKey(id)
init {
uniqueIndex(batchId, invoiceReqSerialNo)
}
}
@@ -15,6 +15,7 @@ object SysUserTable : Table("sys_user") {
val avatar = text("avatar").nullable()
val orgId = uuid("org_id").nullable()
val status = varchar("status", 20).default("ENABLED")
val apiKey = varchar("api_key", 128).nullable().uniqueIndex()
val tokenVersion = integer("token_version").default(1)
val lastLoginAt = timestampWithTimeZone("last_login_at").nullable()
val lastLoginIp = varchar("last_login_ip", 64).nullable()
@@ -11,6 +11,7 @@ data class UserListItem(
val status: String,
val statusLabel: String,
val roleCodes: List<String>,
val apiKey: String? = null,
)
@Serializable
@@ -26,6 +27,7 @@ data class UserDetailResponse(
val status: String,
val statusLabel: String,
val roleIds: List<String>,
val apiKey: String? = null,
val taxpayerNum: String? = null,
val account: String? = null,
val taxPassword: String? = null,
@@ -0,0 +1,92 @@
package com.bbit.ticket.entity.openapi
import com.bbit.ticket.entity.request.InvoiceItem
import com.bbit.ticket.entity.request.OrderInfo
import com.bbit.ticket.entity.request.VariableLevyProof
import kotlinx.serialization.Serializable
@Serializable
data class OpenBlueInvoiceCreateRequest(
val requestNo: String? = null,
val invoiceReqSerialNo: String,
val invoiceIssueKindCode: String = "82",
val buyerName: String,
val purchaseInvSellerIdType: String? = null,
val buyerTaxpayerNum: String? = null,
val naturalPersonFlag: String? = null,
val buyerAddress: String? = null,
val buyerTel: String? = null,
val buyerBankName: String? = null,
val buyerBankAccount: String? = null,
val showBuyerBank: String? = null,
val showSellerBank: String? = null,
val showBuyerAddrTel: String? = null,
val showSellerAddrTel: String? = null,
val variableLevyFlag: String? = null,
val casherName: String? = null,
val reviewerName: String? = null,
val takerName: String? = null,
val takerTel: String? = null,
val takerEmail: String? = null,
val specialInvoiceKind: String? = null,
val remark: String? = null,
val definedData: String? = null,
val tradeNo: String? = null,
val shopNum: String? = null,
val itemList: List<InvoiceItem>,
val variableLevyProofList: List<VariableLevyProof>? = null,
val orderList: List<OrderInfo>? = null,
)
@Serializable
data class OpenBlueInvoiceCreateResponse(
val requestNo: String? = null,
val invoiceReqSerialNo: String,
val status: String,
)
@Serializable
data class OpenBlueInvoiceQueryResponse(
val invoiceReqSerialNo: String,
val status: String,
val code: String? = null,
val message: String? = null,
val invoiceCode: String? = null,
val invoiceNo: String? = null,
val electronicInvoiceNo: String? = null,
val invoiceDate: String? = null,
val buyerName: String? = null,
val totalAmount: String? = null,
val downloadUrl: String? = null,
)
@Serializable
data class OpenBlueInvoiceSampleResponse(
val invoiceReqSerialNo: String,
val downloadUrl: String? = null,
)
@Serializable
data class OpenBlueInvoiceBatchCreateRequest(
val batchNo: String,
val items: List<OpenBlueInvoiceCreateRequest> = emptyList(),
)
@Serializable
data class OpenBlueInvoiceBatchCreateResponse(
val batchNo: String,
val status: String,
val total: Int,
)
@Serializable
data class OpenBlueInvoiceBatchQueryResponse(
val batchNo: String,
val status: String,
val total: Int,
val success: Int,
val failed: Int,
val processing: Int,
val resolved: Boolean,
val items: List<OpenBlueInvoiceQueryResponse>,
)
@@ -14,6 +14,7 @@ import kotlin.uuid.Uuid
@Serializable
data class InvoiceHistoryItem(
val id: Uuid,
val batchNo: String? = null,
/** 发票请求流水号 */
val invoiceReqSerialNo: String,
@@ -0,0 +1,123 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.route.openapi
import com.bbit.ticket.dao.system.LogDao
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.PTException
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateRequest
import com.bbit.ticket.service.openapi.OpenBlueInvoiceService
import com.bbit.ticket.utils.requireOpenApiPrincipal
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.plugins.myJson
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerOpenBlueInvoiceRoutes() {
post {
val principal = call.requireOpenApiPrincipal()
val request = call.receive<OpenBlueInvoiceCreateRequest>()
call.respondOpenApi(principal.apiKey, principal.username, myJson.encodeToString(request)) {
OpenBlueInvoiceService.createSingle(principal, request)
}
}
get("/{invoiceReqSerialNo}") {
val principal = call.requireOpenApiPrincipal()
val invoiceReqSerialNo = call.parameters["invoiceReqSerialNo"].orEmpty()
call.respondOpenApi(principal.apiKey, principal.username, null) {
OpenBlueInvoiceService.querySingle(principal, invoiceReqSerialNo)
}
}
get("/sample/{invoiceReqSerialNo}") {
val principal = call.requireOpenApiPrincipal()
val invoiceReqSerialNo = call.parameters["invoiceReqSerialNo"].orEmpty()
call.respondOpenApi(principal.apiKey, principal.username, null) {
OpenBlueInvoiceService.sample(principal, invoiceReqSerialNo)
}
}
post("/batches") {
val principal = call.requireOpenApiPrincipal()
val request = call.receive<OpenBlueInvoiceBatchCreateRequest>()
call.respondOpenApi(principal.apiKey, principal.username, myJson.encodeToString(request)) {
val response = OpenBlueInvoiceService.createBatch(principal, request)
call.application.launch {
OpenBlueInvoiceService.processBatch(principal, request.batchNo)
}
response
}
}
get("/batches/{batchNo}") {
val principal = call.requireOpenApiPrincipal()
val batchNo = call.parameters["batchNo"].orEmpty()
call.respondOpenApi(principal.apiKey, principal.username, null) {
OpenBlueInvoiceService.queryBatch(principal, batchNo)
}
}
}
private suspend inline fun <reified T> ApplicationCall.respondOpenApi(
appKey: String?,
appName: String?,
requestBody: String?,
crossinline block: suspend () -> T,
) {
val start = TimeSource.Monotonic.markNow()
try {
val data = block()
val response = ok(data)
val responseBody = myJson.encodeToString(response)
respond(response)
saveOpenApiLog(appKey, appName, requestBody, response.code, responseBody, "SUCCESS", null, start)
} catch (e: PTException) {
val response = fail(code = e.code, message = e.message, traceId = e.serialNo)
respond(response)
saveOpenApiLog(appKey, appName, requestBody, e.code, myJson.encodeToString(response), "FAILED", e.message, start)
} catch (e: BizException) {
val response = fail(code = e.errorCode, message = e.message)
respond(e.status, response)
saveOpenApiLog(appKey, appName, requestBody, e.errorCode, myJson.encodeToString(response), "FAILED", e.message, start)
} catch (e: Exception) {
val response = fail(code = "-1", message = e.message ?: "开放接口调用失败")
respond(response)
saveOpenApiLog(appKey, appName, requestBody, "-1", myJson.encodeToString(response), "FAILED", e.message, start)
}
}
private suspend fun ApplicationCall.saveOpenApiLog(
appKey: String?,
appName: String?,
requestBody: String?,
responseCode: String?,
responseBody: String?,
status: String,
errorMessage: String?,
start: TimeSource.Monotonic.ValueTimeMark,
) = dbQuery {
LogDao.saveApiAccessLog(
call = this@saveOpenApiLog,
appKey = appKey,
appName = appName,
requestBody = requestBody,
responseCode = responseCode,
responseBody = responseBody,
status = status,
errorMessage = errorMessage,
costMs = start.elapsedNow().inWholeMilliseconds,
)
}
@@ -18,6 +18,7 @@ import com.bbit.ticket.entity.request.UpdatePresetDataRequest
import com.bbit.ticket.service.piaotong.PTAuthService
import com.bbit.ticket.service.piaotong.PTConfigService
import com.bbit.ticket.utils.requireCurrentUser
import com.bbit.ticket.utils.requirePtProfile
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
@@ -30,14 +31,9 @@ fun Route.registerPTAuthRoutes() {
get("/info") {
try {
val currentUser = call.requireCurrentUser()
val taxpayerNum = currentUser.taxPayerNum
val account = currentUser.taxAccount
if(taxpayerNum == null || account == null){
call.respond(fail(code = "-1", message = "请先完成纳税人信息填写"))
return@get
}
val profile = currentUser.requirePtProfile()
val response = PTAuthService.getTaxBureauAccountAuthStatus(
TaxBureauAuthReq(taxpayerNum, account)
TaxBureauAuthReq(profile.taxpayerNum, profile.taxAccount)
)
call.respond(ok(response))
} catch (e: PTException) {
@@ -117,7 +113,7 @@ fun Route.registerPTAuthRoutes() {
try {
val currentUser = call.requireCurrentUser()
if (currentUser.taxPayerNum == null) {
call.respond(fail(code = "-1", message = "请先完成企业信息填写"))
call.respond(fail(code = "-1", message = "请先完善用户信息"))
} else {
val response = PTConfigService.getDigitalAccount(currentUser.id)
if (response == null) {
@@ -172,10 +168,11 @@ fun Route.registerPTAuthRoutes() {
try {
val qrcodeType = call.request.queryParameters["qrcodeType"]
val currentUser = call.requireCurrentUser()
val profile = currentUser.requirePtProfile()
val response = PTAuthService.getAuthenticationQrcode(
AuthQrcodeRequest(
taxpayerNum = currentUser.taxPayerNum ?: "",
account = currentUser.taxAccount ?: "",
taxpayerNum = profile.taxpayerNum,
account = profile.taxAccount,
qrcodeType = qrcodeType
)
)
@@ -12,6 +12,7 @@ import com.bbit.ticket.entity.request.RedCreateRequest
import com.bbit.ticket.service.piaotong.PTBlueService
import com.bbit.ticket.service.piaotong.PTRedService
import com.bbit.ticket.utils.requireCurrentUser
import com.bbit.ticket.utils.requirePtProfile
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.server.request.receive
@@ -23,7 +24,7 @@ import io.ktor.server.routing.get
import io.ktor.server.routing.post
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerPTiInvoiceRoutes() {
fun Route.registerPTInvoiceRoutes() {
/**
* 创建红票任务
*/
@@ -51,7 +52,7 @@ fun Route.registerPTiInvoiceRoutes() {
try {
val currentUser = call.requireCurrentUser()
val req = call.receive<AskInvoiceRequest>()
val response = PTBlueService.invoiceBlue(req, currentUser.id)
val response = PTBlueService.invoiceBlue(req, currentUser)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
@@ -71,14 +72,24 @@ fun Route.registerPTiInvoiceRoutes() {
val pageSize = call.request.queryParameters["pageSize"]?.toIntOrNull() ?: 20
val invoiceType = call.request.queryParameters["invoiceType"]
val isSuccess = call.request.queryParameters["isSuccess"]?.toBooleanStrictOrNull()
val batchNo = call.request.queryParameters["batchNo"]
val response = PTBlueService.getInvoiceBlueHistory(
currentUser.id, page, pageSize, invoiceType, isSuccess
currentUser.id, page, pageSize, invoiceType, isSuccess, batchNo
)
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询开票历史失败"))
}
}
get("/invoiceBatchNos") {
try {
val currentUser = call.requireCurrentUser()
val response = PTBlueService.listBatchNos(currentUser.id)
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询批次号列表失败"))
}
}
get("/invoiceDetail") {
try {
val currentUser = call.requireCurrentUser()
@@ -187,7 +198,7 @@ fun Route.registerPTiInvoiceRoutes() {
}
val response = PTBlueService.queryInvoiceAllInfo(
QueryInvoiceRequest(
taxpayerNum = currentUser.taxPayerNum ?: "",
taxpayerNum = currentUser.requirePtProfile().taxpayerNum,
invoiceReqSerialNo = invoiceReqSerialNo
)
)
@@ -0,0 +1,389 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.service.openapi
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchQueryResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateRequest
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceQueryResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceSampleResponse
import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.service.piaotong.PTBlueService
import com.bbit.ticket.utils.OpenApiPrincipal
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.plugins.myJson
import io.ktor.http.HttpStatusCode
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object OpenBlueInvoiceService {
private const val MAX_BATCH_SIZE = 100
suspend fun createSingle(
principal: OpenApiPrincipal,
request: OpenBlueInvoiceCreateRequest,
): OpenBlueInvoiceCreateResponse {
validateCreateRequest(request)
val existing = PTBlueService.getInvoiceDetail(principal.userId, request.invoiceReqSerialNo)
if (existing != null) {
return OpenBlueInvoiceCreateResponse(
requestNo = request.requestNo,
invoiceReqSerialNo = request.invoiceReqSerialNo,
status = existing.status,
)
}
PTBlueService.createBlueInvoice(request.toAskInvoiceRequest(principal), principal.userId)
val detail = PTBlueService.getInvoiceDetail(principal.userId, request.invoiceReqSerialNo)
return OpenBlueInvoiceCreateResponse(
requestNo = request.requestNo,
invoiceReqSerialNo = request.invoiceReqSerialNo,
status = detail?.status ?: "PROCESSING",
)
}
suspend fun querySingle(principal: OpenApiPrincipal, invoiceReqSerialNo: String): OpenBlueInvoiceQueryResponse {
if (invoiceReqSerialNo.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "invoiceReqSerialNo 不能为空")
}
runCatching {
PTBlueService.syncInvoiceFromPT(principal.userId, invoiceReqSerialNo, principal.taxPayerNum)
}
val detail = PTBlueService.getInvoiceDetail(principal.userId, invoiceReqSerialNo)
?: throw BizException(ErrorCode.BAD_REQUEST.code, "未找到该发票记录", HttpStatusCode.NotFound)
val downloadUrl = PTBlueService.getInvoiceDownloadUrl(principal.userId, invoiceReqSerialNo)?.downloadUrl
return OpenBlueInvoiceQueryResponse(
invoiceReqSerialNo = detail.invoiceReqSerialNo,
status = detail.status,
code = detail.code,
message = detail.msg,
invoiceCode = detail.invoiceCode,
invoiceNo = detail.invoiceNo,
electronicInvoiceNo = detail.electronicInvoiceNo,
invoiceDate = detail.issuedAt,
buyerName = detail.buyerName,
totalAmount = detail.totalAmount,
downloadUrl = downloadUrl,
)
}
suspend fun sample(principal: OpenApiPrincipal, invoiceReqSerialNo: String): OpenBlueInvoiceSampleResponse {
val detail = PTBlueService.getInvoiceDetail(principal.userId, invoiceReqSerialNo)
?: throw BizException(ErrorCode.BAD_REQUEST.code, "未找到该发票记录", HttpStatusCode.NotFound)
val downloadUrl = PTBlueService.getInvoiceDownloadUrl(principal.userId, detail.invoiceReqSerialNo)?.downloadUrl
return OpenBlueInvoiceSampleResponse(detail.invoiceReqSerialNo, downloadUrl)
}
suspend fun createBatch(
principal: OpenApiPrincipal,
request: OpenBlueInvoiceBatchCreateRequest,
): OpenBlueInvoiceBatchCreateResponse {
validateBatchCreateRequest(request)
val exists = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq principal.userId) and
(OpenInvoiceBatchTable.batchNo eq request.batchNo)
}
.singleOrNull() != null
}
if (exists) {
throw BizException(ErrorCode.BAD_REQUEST.code, "批次号已存在,一个批次只能开票一次")
}
val now = OffsetDateTime.now()
dbQuery {
val batchId = OpenInvoiceBatchTable.insert {
it[userId] = principal.userId
it[batchNo] = request.batchNo
it[totalCount] = request.items.size
it[successCount] = 0
it[failedCount] = 0
it[processingCount] = request.items.size
it[status] = "PROCESSING"
it[resolved] = false
it[createdAt] = now
}[OpenInvoiceBatchTable.id]
request.items.forEach { item ->
OpenInvoiceBatchItemTable.insert {
it[OpenInvoiceBatchItemTable.batchId] = batchId
it[requestNo] = item.requestNo
it[invoiceReqSerialNo] = item.invoiceReqSerialNo
it[originalRequestBody] = myJson.encodeToString(item)
it[status] = "PENDING"
it[createdAt] = now
}
}
}
return OpenBlueInvoiceBatchCreateResponse(
batchNo = request.batchNo,
status = "PROCESSING",
total = request.items.size,
)
}
suspend fun processBatch(principal: OpenApiPrincipal, batchNo: String) {
val batch = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq principal.userId) and
(OpenInvoiceBatchTable.batchNo eq batchNo)
}
.singleOrNull()
} ?: return
val batchId = batch[OpenInvoiceBatchTable.id]
val items = dbQuery {
OpenInvoiceBatchItemTable.selectAll()
.where { OpenInvoiceBatchItemTable.batchId eq batchId }
.orderBy(OpenInvoiceBatchItemTable.createdAt, SortOrder.ASC)
.map { itemRow ->
val body = itemRow[OpenInvoiceBatchItemTable.originalRequestBody]
?: throw BizException(ErrorCode.INTERNAL_SERVER_ERROR.code, "缺少原始请求体")
itemRow[OpenInvoiceBatchItemTable.invoiceReqSerialNo] to
myJson.decodeFromString<OpenBlueInvoiceCreateRequest>(body)
}
}
items.forEach { (invoiceReqSerialNo, request) ->
updateBatchItem(batchId, invoiceReqSerialNo, "PROCESSING", null, null)
runCatching {
createSingle(principal, request)
}.onSuccess { response ->
updateBatchItem(batchId, invoiceReqSerialNo, response.status, null, null)
}.onFailure { error ->
val code = (error as? BizException)?.errorCode
?: (error as? com.bbit.ticket.entity.common.PTException)?.code
?: ErrorCode.INTERNAL_SERVER_ERROR.code
updateBatchItem(batchId, invoiceReqSerialNo, "FAILED", code, error.message)
}
refreshBatchSummary(batchId)
}
refreshBatchSummary(batchId)
}
suspend fun queryBatch(principal: OpenApiPrincipal, batchNo: String): OpenBlueInvoiceBatchQueryResponse {
val batch = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq principal.userId) and
(OpenInvoiceBatchTable.batchNo eq batchNo)
}
.singleOrNull()
} ?: throw BizException(ErrorCode.BAD_REQUEST.code, "未找到该批次", HttpStatusCode.NotFound)
val batchId = batch[OpenInvoiceBatchTable.id]
val itemSnapshots = dbQuery {
OpenInvoiceBatchItemTable.selectAll()
.where { OpenInvoiceBatchItemTable.batchId eq batchId }
.orderBy(OpenInvoiceBatchItemTable.createdAt, SortOrder.ASC)
.map { itemRow ->
BatchItemSnapshot(
invoiceReqSerialNo = itemRow[OpenInvoiceBatchItemTable.invoiceReqSerialNo],
status = itemRow[OpenInvoiceBatchItemTable.status],
errorCode = itemRow[OpenInvoiceBatchItemTable.errorCode],
errorMessage = itemRow[OpenInvoiceBatchItemTable.errorMessage],
)
}
}
val items = itemSnapshots.map { buildBatchInvoiceQueryResponse(principal, batchId, it) }
refreshBatchSummary(batchId)
val refreshedBatch = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where { OpenInvoiceBatchTable.id eq batchId }
.single()
}
return OpenBlueInvoiceBatchQueryResponse(
batchNo = refreshedBatch[OpenInvoiceBatchTable.batchNo],
status = refreshedBatch[OpenInvoiceBatchTable.status],
total = refreshedBatch[OpenInvoiceBatchTable.totalCount],
success = refreshedBatch[OpenInvoiceBatchTable.successCount],
failed = refreshedBatch[OpenInvoiceBatchTable.failedCount],
processing = refreshedBatch[OpenInvoiceBatchTable.processingCount],
resolved = refreshedBatch[OpenInvoiceBatchTable.resolved],
items = items,
)
}
private suspend fun buildBatchInvoiceQueryResponse(
principal: OpenApiPrincipal,
batchId: Uuid,
item: BatchItemSnapshot,
): OpenBlueInvoiceQueryResponse {
if (item.status != "PENDING") {
runCatching {
PTBlueService.syncInvoiceFromPT(
principal.userId,
item.invoiceReqSerialNo,
principal.taxPayerNum,
)
}
}
val detail = PTBlueService.getInvoiceDetail(principal.userId, item.invoiceReqSerialNo)
if (detail == null) {
return OpenBlueInvoiceQueryResponse(
invoiceReqSerialNo = item.invoiceReqSerialNo,
status = item.status,
code = item.errorCode,
message = item.errorMessage,
)
}
val downloadUrl = PTBlueService.getInvoiceDownloadUrl(principal.userId, detail.invoiceReqSerialNo)?.downloadUrl
updateBatchItem(
batchId = batchId,
invoiceReqSerialNo = detail.invoiceReqSerialNo,
status = detail.status,
errorCode = if (detail.status == "FAILED") detail.code else null,
errorMessage = if (detail.status == "FAILED") detail.msg else null,
)
return OpenBlueInvoiceQueryResponse(
invoiceReqSerialNo = detail.invoiceReqSerialNo,
status = detail.status,
code = detail.code,
message = detail.msg,
invoiceCode = detail.invoiceCode,
invoiceNo = detail.invoiceNo,
electronicInvoiceNo = detail.electronicInvoiceNo,
invoiceDate = detail.issuedAt,
buyerName = detail.buyerName,
totalAmount = detail.totalAmount,
downloadUrl = downloadUrl,
)
}
private suspend fun updateBatchItem(
batchId: Uuid,
invoiceReqSerialNo: String,
status: String,
errorCode: String?,
errorMessage: String?,
) = dbQuery {
OpenInvoiceBatchItemTable.update({
(OpenInvoiceBatchItemTable.batchId eq batchId) and
(OpenInvoiceBatchItemTable.invoiceReqSerialNo eq invoiceReqSerialNo)
}) {
it[OpenInvoiceBatchItemTable.status] = status
it[OpenInvoiceBatchItemTable.errorCode] = errorCode
it[OpenInvoiceBatchItemTable.errorMessage] = errorMessage
it[updatedAt] = OffsetDateTime.now()
}
}
private suspend fun refreshBatchSummary(batchId: Uuid) = dbQuery {
val statuses = OpenInvoiceBatchItemTable.selectAll()
.where { OpenInvoiceBatchItemTable.batchId eq batchId }
.map { it[OpenInvoiceBatchItemTable.status] }
val success = statuses.count { it == "SUCCESS" }
val failed = statuses.count { it == "FAILED" }
val processing = statuses.size - success - failed
val status = when {
processing > 0 -> "PROCESSING"
failed > 0 -> if (success > 0) "PARTIAL_FAILED" else "FAILED"
else -> "SUCCESS"
}
OpenInvoiceBatchTable.update({ OpenInvoiceBatchTable.id eq batchId }) {
it[successCount] = success
it[failedCount] = failed
it[processingCount] = processing
it[OpenInvoiceBatchTable.status] = status
it[resolved] = status == "SUCCESS"
it[updatedAt] = OffsetDateTime.now()
}
}
private fun validateBatchCreateRequest(request: OpenBlueInvoiceBatchCreateRequest) {
if (request.batchNo.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "batchNo 不能为空")
}
if (request.items.isEmpty()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "批量明细不能为空")
}
if (request.items.size > MAX_BATCH_SIZE) {
throw BizException(ErrorCode.BAD_REQUEST.code, "单次批量最多支持 $MAX_BATCH_SIZE 条")
}
request.items.forEach(::validateCreateRequest)
if (request.items.map { it.invoiceReqSerialNo }.distinct().size != request.items.size) {
throw BizException(ErrorCode.BAD_REQUEST.code, "批量明细中 invoiceReqSerialNo 不能重复")
}
}
private fun validateCreateRequest(request: OpenBlueInvoiceCreateRequest) {
if (request.invoiceReqSerialNo.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "invoiceReqSerialNo 不能为空")
}
if (request.buyerName.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "buyerName 不能为空")
}
if (request.itemList.isEmpty()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "itemList 不能为空")
}
}
private fun OpenBlueInvoiceCreateRequest.toAskInvoiceRequest(principal: OpenApiPrincipal): AskInvoiceRequest =
AskInvoiceRequest(
taxpayerNum = principal.taxPayerNum,
invoiceReqSerialNo = invoiceReqSerialNo,
invoiceIssueKindCode = invoiceIssueKindCode,
buyerName = buyerName,
purchaseInvSellerIdType = purchaseInvSellerIdType,
buyerTaxpayerNum = buyerTaxpayerNum,
naturalPersonFlag = naturalPersonFlag,
buyerAddress = buyerAddress,
buyerTel = buyerTel,
buyerBankName = buyerBankName,
buyerBankAccount = buyerBankAccount,
showBuyerBank = showBuyerBank,
showSellerBank = showSellerBank,
showBuyerAddrTel = showBuyerAddrTel,
showSellerAddrTel = showSellerAddrTel,
account = principal.taxAccount,
variableLevyFlag = variableLevyFlag,
casherName = casherName,
reviewerName = reviewerName,
takerName = takerName,
takerTel = takerTel,
takerEmail = takerEmail,
specialInvoiceKind = specialInvoiceKind,
remark = remark,
definedData = definedData,
tradeNo = tradeNo ?: requestNo,
shopNum = shopNum,
itemList = itemList,
variableLevyProofList = variableLevyProofList,
orderList = orderList,
)
private data class BatchItemSnapshot(
val invoiceReqSerialNo: String,
val status: String,
val errorCode: String?,
val errorMessage: String?,
)
}
@@ -12,6 +12,8 @@ import com.bbit.ticket.entity.response.QueryInvoiceResult
import com.bbit.ticket.entity.response.InvoiceCreateResponse
import com.bbit.ticket.entity.response.InvoiceDetailResponse
import com.bbit.ticket.entity.response.InvoiceHistoryItem
import com.bbit.ticket.utils.CurrentUser
import com.bbit.ticket.utils.requirePtProfile
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes
import com.bbit.ticket.utils.plugins.dbQuery
@@ -20,6 +22,9 @@ import kotlin.uuid.Uuid
object PTBlueService {
suspend fun listBatchNos(userId: Uuid): List<String> =
dbQuery { BlueInvoiceDao.listBatchNos(userId) }
/**
* 查询票通同步发票信息(支持插入和更新)
*
@@ -45,9 +50,25 @@ object PTBlueService {
/**
* 蓝票接口调用
*/
suspend fun invoiceBlue(req: AskInvoiceRequest, userId: Uuid): String {
PTClient.ptPost<AskInvoiceRequest, InvoiceCreateResponse>("invoiceBlue.pt", req)
suspend fun invoiceBlue(req: AskInvoiceRequest, user: CurrentUser): String {
val profile = user.requirePtProfile()
return createBlueInvoice(
req.copy(
taxpayerNum = profile.taxpayerNum,
account = req.account ?: profile.taxAccount,
),
user.id,
)
}
suspend fun createBlueInvoice(req: AskInvoiceRequest, userId: Uuid): String {
dbQuery { BlueInvoiceDao.addInvoice(userId, req) }
try {
PTClient.ptPost<AskInvoiceRequest, InvoiceCreateResponse>("invoiceBlue.pt", req)
} catch (e: Exception) {
dbQuery { BlueInvoiceDao.markInvoiceFailed(userId, req.invoiceReqSerialNo, e.message) }
throw e
}
// 创建后立即同步一次(非关键,失败忽略)
try {
syncInvoiceFromPT(userId, req.invoiceReqSerialNo, req.taxpayerNum)
@@ -63,9 +84,10 @@ object PTBlueService {
page: Int,
pageSize: Int,
invoiceType: String? = null,
isSuccess: Boolean? = null
isSuccess: Boolean? = null,
batchNo: String? = null,
): PageResult<InvoiceHistoryItem> =
dbQuery { BlueInvoiceDao.invoiceHistory(userId, page, pageSize, invoiceType, isSuccess) }
dbQuery { BlueInvoiceDao.invoiceHistory(userId, page, pageSize, invoiceType, isSuccess, batchNo) }
/**
* 查询发票完整详情
@@ -10,6 +10,7 @@ import com.bbit.ticket.entity.response.InvoiceDownloadUrlResponse
import com.bbit.ticket.entity.response.QuickRedInvoiceResponse
import com.bbit.ticket.entity.response.RedInvoiceInfoResponse
import com.bbit.ticket.utils.CurrentUser
import com.bbit.ticket.utils.requirePtProfile
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.net.PTClient
import io.ktor.client.request.get
@@ -27,13 +28,14 @@ object PTRedService {
* 红票接口调用
*/
suspend fun invoiceRed(user: CurrentUser,req:RedCreateRequest): String {
val profile = user.requirePtProfile()
val invoiceReqSerialNo = PTClient.ptDate()
val historyId = Uuid.parse(req.historyId)
val his = dbQuery {
HistoryDao.findByHistory(historyId, user.id)
}
val req = QuickRedInvoiceRequest(
taxpayerNum = user.taxPayerNum!!,
taxpayerNum = profile.taxpayerNum,
invoiceReqSerialNo = invoiceReqSerialNo,
invoiceCode = his.invoiceCode,
invoiceNo = his.invoiceNo,
@@ -41,7 +43,7 @@ object PTRedService {
blueInvoiceDate = his.blueInvoiceDate,
redReason = req.redReason,
amount = his.totalAmount?.negate()?.toPlainString()?:"0.0",
account = user.taxAccount,
account = profile.taxAccount,
invoiceKind = his.invoiceKind,
takerName = req.takerName,
takerTel = req.takerTel,
@@ -51,7 +53,7 @@ object PTRedService {
dbQuery { RedInvoiceDao.addRedInvoice(user.id, historyId, req) }
// 创建后立即同步一次(非关键,失败忽略)
try {
PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, user.taxPayerNum!!)
PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, profile.taxpayerNum)
} catch (_: Exception) { }
return "操作成功"
}
@@ -0,0 +1,14 @@
package com.bbit.ticket.utils
import java.security.SecureRandom
import java.util.Base64
object ApiKeyUtil {
private val random = SecureRandom()
fun generate(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return "tk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
}
@@ -0,0 +1,78 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.utils
import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.utils.plugins.dbQuery
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
data class OpenApiPrincipal(
val userId: Uuid,
val username: String,
val apiKey: String,
val taxPayerNum: String,
val taxAccount: String,
)
suspend fun ApplicationCall.requireOpenApiPrincipal(): OpenApiPrincipal {
val apiKey = request.headers["X-Api-Key"]?.trim()
?: throw BizException(ErrorCode.UNAUTHORIZED.code, "缺少 X-Api-Key", HttpStatusCode.Unauthorized)
if (apiKey.isBlank()) {
throw BizException(ErrorCode.UNAUTHORIZED.code, "缺少 X-Api-Key", HttpStatusCode.Unauthorized)
}
val row = dbQuery {
SysUserTable.selectAll()
.where {
(SysUserTable.apiKey eq apiKey) and
(SysUserTable.status eq "ENABLED") and
SysUserTable.deletedAt.isNull()
}
.singleOrNull()
} ?: throw BizException(ErrorCode.UNAUTHORIZED.code, "API Key 无效或已停用", HttpStatusCode.Unauthorized)
val taxpayerNum = row[SysUserTable.taxpayerNum]?.takeIf { it.isNotBlank() }
val taxAccount = row[SysUserTable.taxAccount]?.takeIf { it.isNotBlank() }
if (taxpayerNum == null || taxAccount == null) {
throw BizException(ErrorCode.BAD_REQUEST.code, "请先完善用户信息")
}
dbQuery {
SysUserTable.update({ SysUserTable.id eq row[SysUserTable.id] }) {
it[updatedAt] = OffsetDateTime.now()
}
}
return OpenApiPrincipal(
userId = row[SysUserTable.id],
username = row[SysUserTable.username],
apiKey = apiKey,
taxPayerNum = taxpayerNum,
taxAccount = taxAccount,
)
}
data class PtProfile(
val taxpayerNum: String,
val taxAccount: String,
)
fun CurrentUser.requirePtProfile(): PtProfile {
val taxpayerNum = taxPayerNum?.takeIf { it.isNotBlank() }
val account = taxAccount?.takeIf { it.isNotBlank() }
if (taxpayerNum == null || account == null) {
throw BizException(ErrorCode.BAD_REQUEST.code, "请先完善用户信息")
}
return PtProfile(taxpayerNum, account)
}
@@ -5,6 +5,8 @@ import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceRedTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
import com.bbit.ticket.database.system.SysApiAccessLogTable
import com.bbit.ticket.database.system.SysDictItemTable
import com.bbit.ticket.database.system.SysDictTypeTable
@@ -39,6 +41,8 @@ object DatabaseInitializer {
HistoryInvoiceGoodsTable,
HistoryInvoiceVoucherTable,
HistoryInvoiceOrderTable,
OpenInvoiceBatchTable,
OpenInvoiceBatchItemTable,
)
// 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。
transaction {
@@ -25,6 +25,7 @@ object SeedData {
const val ADMIN_USERNAME = "admin"
const val ADMIN_INIT_PASSWORD = "Admin@123456"
const val ADMIN_INIT_API_KEY = "tk_admin_test_key_please_change"
private const val DEFAULT_ORG_CODE = "DEFAULT_ORG"
private const val SUPER_ADMIN_ROLE_CODE = "SUPER_ADMIN"
@@ -119,6 +120,9 @@ object SeedData {
it[SysUserTable.nickname] = "系统管理员"
it[SysUserTable.orgId] = orgId
it[SysUserTable.status] = "ENABLED"
if (existing[SysUserTable.apiKey].isNullOrBlank()) {
it[SysUserTable.apiKey] = ADMIN_INIT_API_KEY
}
it[SysUserTable.updatedAt] = now
it[SysUserTable.taxpayerNum] = "500102201007206608"
it[SysUserTable.phone] = "13000000000"
@@ -135,6 +139,7 @@ object SeedData {
it[SysUserTable.nickname] = "系统管理员"
it[SysUserTable.orgId] = orgId
it[SysUserTable.status] = "ENABLED"
it[SysUserTable.apiKey] = ADMIN_INIT_API_KEY
it[SysUserTable.tokenVersion] = 1
it[SysUserTable.taxpayerNum] = "500102201007206608"
it[SysUserTable.phone] = "13000000000"
+6 -1
View File
@@ -391,6 +391,7 @@ export interface PageResult<T> {
/** 发票历史记录 */
export interface InvoiceHistoryItem {
id: string
batchNo?: string
/** 发票请求流水号 */
invoiceReqSerialNo: string
/** 销方税号 */
@@ -558,11 +559,15 @@ export const invoiceStatusColorMap: Record<string, string> = {
export function invoiceHistoryApi(
page: number,
pageSize: number,
params?: { invoiceType?: string; isSuccess?: boolean }
params?: { invoiceType?: string; isSuccess?: boolean; batchNo?: string }
): Promise<PageResult<InvoiceHistoryItem>> {
return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize, ...params } })
}
export function invoiceBatchNosApi(): Promise<string[]> {
return http.get('/pt/invoiceBatchNos')
}
// =============================================
// 发票详情
// =============================================
@@ -22,6 +22,14 @@
</button>
</div>
<div class="surface-filters">
<n-select
v-model:value="selectedBatchNo"
:options="batchNoOptions"
clearable
placeholder="批次号"
style="width: 180px"
@update:value="handleFilterChange"
/>
<n-select
v-model:value="selectedStatus"
:options="statusFilterOptions"
@@ -481,6 +489,7 @@ import {
RotateCcw
} from 'lucide-vue-next'
import {
invoiceBatchNosApi,
invoiceDownloadUrlApi,
invoicePreviewBlobApi,
invoiceDetailApi,
@@ -566,6 +575,8 @@ function statusTagType(status: string): 'warning' | 'info' | 'success' | 'error'
const activeTab = ref('BLUE')
const selectedStatus = ref<string | null>(null)
const selectedBatchNo = ref<string | null>(null)
const batchNoOptions = ref<Array<{ label: string; value: string }>>([])
const statusFilterOptions = [
{ label: '全部', value: '' },
@@ -579,12 +590,17 @@ const TAB_FILTERS: Record<string, { invoiceType: string }> = {
}
function getFilterParams() {
const params: { invoiceType: string; isSuccess?: boolean } = { ...TAB_FILTERS[activeTab.value] }
const params: { invoiceType: string; isSuccess?: boolean; batchNo?: string } = {
...TAB_FILTERS[activeTab.value]
}
if (selectedStatus.value === 'SUCCESS') {
params.isSuccess = true
} else if (selectedStatus.value === 'NOT_SUCCESS') {
params.isSuccess = false
}
if (selectedBatchNo.value) {
params.batchNo = selectedBatchNo.value
}
return params
}
@@ -605,10 +621,20 @@ function handleFilterChange() {
function handleReset() {
selectedStatus.value = null
selectedBatchNo.value = null
pagination.page = 1
fetchData()
}
async function fetchBatchNoOptions() {
try {
const res = await invoiceBatchNosApi()
batchNoOptions.value = res.map((batchNo) => ({ label: batchNo, value: batchNo }))
} catch {
batchNoOptions.value = []
}
}
const message = useMessage()
const loading = ref(false)
const dataSource = ref<InvoiceHistoryItem[]>([])
@@ -749,6 +775,16 @@ const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
row.invoiceReqSerialNo
)
},
{
title: '批次',
key: 'batchNo',
width: 180,
ellipsis: { tooltip: true },
render: (row: InvoiceHistoryItem) =>
row.batchNo
? h(NTag, { size: 'small', round: true, type: 'info' }, () => row.batchNo)
: h('span', { style: 'color:#9ca3af' }, '-')
},
{
title: '状态',
key: 'status',
@@ -1078,6 +1114,7 @@ const voucherColumns: DataTableColumns<InvoiceDetailVoucher> = [
]
onMounted(() => {
fetchBatchNoOptions()
fetchData()
})
</script>
+13
View File
@@ -76,6 +76,9 @@
<n-form-item label="邮箱" path="email">
<n-input v-model:value="editForm.email" />
</n-form-item>
<n-form-item v-if="editModal.mode === 'edit'" label="API Key">
<n-input v-model:value="editForm.apiKey" readonly />
</n-form-item>
<n-form-item v-if="editModal.mode === 'create'" label="状态" path="status">
<n-radio-group v-model:value="editForm.status">
<n-radio-button value="ENABLED">启用</n-radio-button>
@@ -237,6 +240,7 @@ const editForm = reactive({
realName: '',
phone: '',
email: '',
apiKey: '',
status: 'ENABLED'
})
@@ -357,6 +361,7 @@ function resetEditForm() {
editForm.realName = ''
editForm.phone = ''
editForm.email = ''
editForm.apiKey = ''
editForm.status = 'ENABLED'
}
@@ -379,6 +384,7 @@ async function openEdit(row: UserListItem) {
editForm.realName = detail.realName ?? ''
editForm.phone = detail.phone ?? ''
editForm.email = detail.email ?? ''
editForm.apiKey = detail.apiKey ?? ''
editForm.status = detail.status
editModal.visible = true
}
@@ -445,6 +451,13 @@ const columns = computed<DataTableColumns<UserListItem>>(() => [
minWidth: 180,
render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-')
},
{
title: 'API Key',
key: 'apiKey',
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.apiKey || '-'
},
{
title: '状态',
key: 'status',
+2
View File
@@ -9,6 +9,7 @@ export interface UserListItem {
status: string
statusLabel?: string
roleCodes: string[]
apiKey?: string | null
}
export interface UserDetail {
@@ -23,6 +24,7 @@ export interface UserDetail {
status: string
statusLabel?: string
roleIds: string[]
apiKey?: string | null
taxpayerNum?: string | null
account?: string | null
taxPassword?: string | null