开放溯源接口
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package ink.snowflake.server.controller
|
package ink.snowflake.server.controller
|
||||||
|
|
||||||
import ink.snowflake.server.model.request.CreateTraceBatchRequest
|
import ink.snowflake.server.model.request.CreateTraceBatchRequest
|
||||||
|
import ink.snowflake.server.model.request.CreateTraceabilityPublicBatchRequest
|
||||||
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
|
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
|
||||||
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
|
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
|
||||||
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
|
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
|
||||||
@@ -19,6 +20,7 @@ import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
|
|||||||
import ink.snowflake.server.utils.AppConfig
|
import ink.snowflake.server.utils.AppConfig
|
||||||
import ink.snowflake.server.utils.OSSUtils
|
import ink.snowflake.server.utils.OSSUtils
|
||||||
import ink.snowflake.server.utils.dao.TraceabilityDao
|
import ink.snowflake.server.utils.dao.TraceabilityDao
|
||||||
|
import ink.snowflake.server.utils.dao.TraceabilityPublicBatchError
|
||||||
import io.ktor.http.ContentType
|
import io.ktor.http.ContentType
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
import io.ktor.http.content.*
|
import io.ktor.http.content.*
|
||||||
@@ -89,6 +91,23 @@ private suspend fun handleUpdateBatchStep(call: io.ktor.server.application.Appli
|
|||||||
call.respond(BaseResponse(data = data))
|
call.respond(BaseResponse(data = data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun handleCreatePublicBatch(call: io.ktor.server.application.ApplicationCall) {
|
||||||
|
val request = call.receive<CreateTraceabilityPublicBatchRequest>()
|
||||||
|
val result = TraceabilityDao.createPublicBatch(request)
|
||||||
|
val data = result.data
|
||||||
|
if (data != null) {
|
||||||
|
call.respond(BaseResponse(message = result.message.ifBlank { "批次已创建并发布" }, data = data))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusCode = when (result.error) {
|
||||||
|
TraceabilityPublicBatchError.NOT_FOUND -> HttpStatusCode.NotFound
|
||||||
|
TraceabilityPublicBatchError.CONFLICT -> HttpStatusCode.Conflict
|
||||||
|
else -> HttpStatusCode.BadRequest
|
||||||
|
}
|
||||||
|
call.respond(statusCode, BaseResponse(status = false, message = result.message.ifBlank { "批次创建失败" }, data = null))
|
||||||
|
}
|
||||||
|
|
||||||
fun Application.Traceability(config: AppConfig) {
|
fun Application.Traceability(config: AppConfig) {
|
||||||
TraceabilityDao.init(config)
|
TraceabilityDao.init(config)
|
||||||
TraceabilityDao.initSchema()
|
TraceabilityDao.initSchema()
|
||||||
@@ -318,6 +337,24 @@ fun Application.Traceability(config: AppConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
route("/public") {
|
route("/public") {
|
||||||
|
route("/integration") {
|
||||||
|
post("/batches") {
|
||||||
|
handleCreatePublicBatch(call)
|
||||||
|
}
|
||||||
|
get("/batches/{id}/public-url") {
|
||||||
|
val id = parseUuidOrNull(call.parameters["id"])
|
||||||
|
if (id == null) {
|
||||||
|
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
val data = TraceabilityDao.getPublicBatchUrl(id)
|
||||||
|
if (data == null) {
|
||||||
|
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
|
||||||
|
return@get
|
||||||
|
}
|
||||||
|
call.respond(BaseResponse(data = data))
|
||||||
|
}
|
||||||
|
}
|
||||||
get("/by-code/{code}") {
|
get("/by-code/{code}") {
|
||||||
val code = call.parameters["code"] ?: ""
|
val code = call.parameters["code"] ?: ""
|
||||||
val increaseScan = call.request.queryParameters["increaseScan"] == "true"
|
val increaseScan = call.request.queryParameters["increaseScan"] == "true"
|
||||||
|
|||||||
@@ -93,6 +93,30 @@ data class CreateTraceBatchRequest(
|
|||||||
val tags: List<String> = emptyList(),
|
val tags: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CreateTraceabilityPublicBatchRequest(
|
||||||
|
val templateId: String,
|
||||||
|
val batchName: String,
|
||||||
|
val batchCode: String,
|
||||||
|
val productName: String = "",
|
||||||
|
val summary: String = "",
|
||||||
|
val coverImage: String = "",
|
||||||
|
val tags: List<String> = emptyList(),
|
||||||
|
val overwrite: Boolean = false,
|
||||||
|
val operatorName: String = "",
|
||||||
|
val completedAt: String? = null,
|
||||||
|
val steps: List<TraceabilityPublicBatchStepRequest> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TraceabilityPublicBatchStepRequest(
|
||||||
|
val templateNodeId: String? = null,
|
||||||
|
val nodeName: String? = null,
|
||||||
|
val operatorName: String = "",
|
||||||
|
val completedAt: String? = null,
|
||||||
|
val values: JsonObject = JsonObject(emptyMap()),
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class UpdateTraceBatchBaseRequest(
|
data class UpdateTraceBatchBaseRequest(
|
||||||
val batchName: String,
|
val batchName: String,
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ data class TraceBatchDetailResponse(
|
|||||||
val publishedAt: String = "",
|
val publishedAt: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TraceabilityPublicBatchResponse(
|
||||||
|
val batchId: String,
|
||||||
|
val batchCode: String,
|
||||||
|
val publicUrl: String,
|
||||||
|
val status: String,
|
||||||
|
val publishedAt: String = "",
|
||||||
|
val overwritten: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TraceabilityFeedbackResponse(
|
data class TraceabilityFeedbackResponse(
|
||||||
val id: String,
|
val id: String,
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import ink.snowflake.server.model.database.TraceabilityPreviewPagesTable
|
|||||||
import ink.snowflake.server.model.database.TraceabilityTemplateNodesTable
|
import ink.snowflake.server.model.database.TraceabilityTemplateNodesTable
|
||||||
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
|
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
|
||||||
import ink.snowflake.server.model.request.CreateTraceBatchRequest
|
import ink.snowflake.server.model.request.CreateTraceBatchRequest
|
||||||
|
import ink.snowflake.server.model.request.CreateTraceabilityPublicBatchRequest
|
||||||
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
|
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
|
||||||
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
|
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
|
||||||
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
|
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
|
||||||
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
|
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
|
||||||
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
|
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
|
||||||
|
import ink.snowflake.server.model.request.TraceabilityPublicBatchStepRequest
|
||||||
import ink.snowflake.server.model.request.TracePreviewNodeRequest
|
import ink.snowflake.server.model.request.TracePreviewNodeRequest
|
||||||
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
|
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
|
||||||
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
|
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
|
||||||
@@ -34,18 +36,21 @@ import ink.snowflake.server.model.response.TraceTemplateSummaryResponse
|
|||||||
import ink.snowflake.server.model.response.TraceabilityFeedbackResponse
|
import ink.snowflake.server.model.response.TraceabilityFeedbackResponse
|
||||||
import ink.snowflake.server.model.response.TraceabilityFileAssetResponse
|
import ink.snowflake.server.model.response.TraceabilityFileAssetResponse
|
||||||
import ink.snowflake.server.model.response.TraceabilityOverviewResponse
|
import ink.snowflake.server.model.response.TraceabilityOverviewResponse
|
||||||
|
import ink.snowflake.server.model.response.TraceabilityPublicBatchResponse
|
||||||
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
|
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
|
||||||
import ink.snowflake.server.utils.AppConfig
|
import ink.snowflake.server.utils.AppConfig
|
||||||
import ink.snowflake.server.utils.OSSUtils
|
import ink.snowflake.server.utils.OSSUtils
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import kotlinx.serialization.json.put
|
import kotlinx.serialization.json.put
|
||||||
import org.jetbrains.exposed.v1.core.SortOrder
|
import org.jetbrains.exposed.v1.core.SortOrder
|
||||||
@@ -58,15 +63,48 @@ import org.jetbrains.exposed.v1.jdbc.insertAndGetId
|
|||||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||||
import org.jetbrains.exposed.v1.jdbc.update
|
import org.jetbrains.exposed.v1.jdbc.update
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
enum class TraceabilityPublicBatchError {
|
||||||
|
BAD_REQUEST,
|
||||||
|
NOT_FOUND,
|
||||||
|
CONFLICT,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TraceabilityPublicBatchOperationResult(
|
||||||
|
val data: TraceabilityPublicBatchResponse? = null,
|
||||||
|
val message: String = "",
|
||||||
|
val error: TraceabilityPublicBatchError? = null,
|
||||||
|
)
|
||||||
|
|
||||||
object TraceabilityDao {
|
object TraceabilityDao {
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
|
private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
private var publicPreviewBaseUrl: String = "http://127.0.0.1:8081"
|
private var publicPreviewBaseUrl: String = "http://127.0.0.1:8081"
|
||||||
|
|
||||||
|
private data class PreparedPublicBatchStep(
|
||||||
|
val node: TraceTemplateNodeResponse,
|
||||||
|
val values: JsonObject,
|
||||||
|
val operatorName: String,
|
||||||
|
val completedAt: Instant,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class PreparedPublicBatchStepsResult(
|
||||||
|
val steps: List<PreparedPublicBatchStep> = emptyList(),
|
||||||
|
val error: TraceabilityPublicBatchOperationResult? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class FieldNormalizationResult(
|
||||||
|
val value: JsonElement? = null,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
fun init(config: AppConfig) {
|
fun init(config: AppConfig) {
|
||||||
publicPreviewBaseUrl = config.traceabilityPublicPreviewBaseUrl
|
publicPreviewBaseUrl = config.traceabilityPublicPreviewBaseUrl
|
||||||
}
|
}
|
||||||
@@ -705,6 +743,111 @@ object TraceabilityDao {
|
|||||||
getBatch(batchId)
|
getBatch(batchId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun createPublicBatch(request: CreateTraceabilityPublicBatchRequest): TraceabilityPublicBatchOperationResult = transaction {
|
||||||
|
val templateId = runCatching { UUID.fromString(request.templateId) }.getOrNull()
|
||||||
|
?: return@transaction publicBatchBadRequest("模板ID无效")
|
||||||
|
val template = getTemplate(templateId)
|
||||||
|
?: return@transaction publicBatchNotFound("模板不存在")
|
||||||
|
|
||||||
|
if (template.status != "active") {
|
||||||
|
return@transaction publicBatchBadRequest("模板未发布,请先发布模板后再通过接口创建批次")
|
||||||
|
}
|
||||||
|
|
||||||
|
val batchCode = request.batchCode.trim()
|
||||||
|
val batchName = request.batchName.trim()
|
||||||
|
if (batchCode.isBlank()) {
|
||||||
|
return@transaction publicBatchBadRequest("batchCode 不能为空")
|
||||||
|
}
|
||||||
|
if (batchName.isBlank()) {
|
||||||
|
return@transaction publicBatchBadRequest("batchName 不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
val nowInstant = nowInstant()
|
||||||
|
val preparedSteps = preparePublicBatchSteps(template, request, nowInstant)
|
||||||
|
if (preparedSteps.error != null) {
|
||||||
|
return@transaction preparedSteps.error
|
||||||
|
}
|
||||||
|
val steps = preparedSteps.steps
|
||||||
|
|
||||||
|
val existingBatch = TraceabilityBatchesTable.selectAll()
|
||||||
|
.where { TraceabilityBatchesTable.batchCode eq batchCode }
|
||||||
|
.singleOrNull()
|
||||||
|
val overwritten = existingBatch != null
|
||||||
|
if (existingBatch != null && !request.overwrite) {
|
||||||
|
return@transaction TraceabilityPublicBatchOperationResult(
|
||||||
|
message = "批次编码已存在,如需覆盖请传 overwrite=true",
|
||||||
|
error = TraceabilityPublicBatchError.CONFLICT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val now = timestampLiteral(nowInstant)
|
||||||
|
val tagsJson = json.encodeToString(request.tags.map { it.trim() }.filter { it.isNotBlank() })
|
||||||
|
val batchId = if (existingBatch == null) {
|
||||||
|
TraceabilityBatchesTable.insertAndGetId {
|
||||||
|
it[this.templateId] = templateId
|
||||||
|
it[this.batchName] = batchName
|
||||||
|
it[this.batchCode] = batchCode
|
||||||
|
it[productName] = request.productName.trim()
|
||||||
|
it[summary] = request.summary
|
||||||
|
it[coverImage] = request.coverImage.ifBlank { template.coverImage }
|
||||||
|
it[this.tagsJson] = tagsJson
|
||||||
|
it[status] = "published"
|
||||||
|
it[currentStep] = (steps.size - 1).coerceAtLeast(0)
|
||||||
|
it[publishedAt] = now
|
||||||
|
it[createdAt] = now
|
||||||
|
it[updatedAt] = now
|
||||||
|
}.value
|
||||||
|
} else {
|
||||||
|
val currentBatchId = existingBatch[TraceabilityBatchesTable.id].value
|
||||||
|
TraceabilityBatchStepsTable.deleteWhere { TraceabilityBatchStepsTable.batchId eq currentBatchId }
|
||||||
|
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq currentBatchId }) {
|
||||||
|
it[this.templateId] = templateId
|
||||||
|
it[this.batchName] = batchName
|
||||||
|
it[this.batchCode] = batchCode
|
||||||
|
it[productName] = request.productName.trim()
|
||||||
|
it[summary] = request.summary
|
||||||
|
it[coverImage] = request.coverImage.ifBlank { template.coverImage }
|
||||||
|
it[this.tagsJson] = tagsJson
|
||||||
|
it[status] = "published"
|
||||||
|
it[currentStep] = (steps.size - 1).coerceAtLeast(0)
|
||||||
|
it[publishedAt] = now
|
||||||
|
it[updatedAt] = now
|
||||||
|
}
|
||||||
|
currentBatchId
|
||||||
|
}
|
||||||
|
|
||||||
|
steps.forEach { step ->
|
||||||
|
TraceabilityBatchStepsTable.insertAndGetId {
|
||||||
|
it[this.batchId] = batchId
|
||||||
|
it[this.templateNodeId] = UUID.fromString(step.node.id)
|
||||||
|
it[sort] = step.node.sort
|
||||||
|
it[category] = step.node.category
|
||||||
|
it[name] = step.node.name
|
||||||
|
it[description] = step.node.description
|
||||||
|
it[locked] = step.node.locked
|
||||||
|
it[consumerVisible] = step.node.consumerVisible
|
||||||
|
it[fieldsJson] = json.encodeToString(step.node.fields)
|
||||||
|
it[status] = "completed"
|
||||||
|
it[operatorName] = step.operatorName
|
||||||
|
it[valuesJson] = json.encodeToString(step.values)
|
||||||
|
it[completedAt] = timestampLiteral(step.completedAt)
|
||||||
|
it[createdAt] = now
|
||||||
|
it[updatedAt] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val batch = getBatch(batchId)
|
||||||
|
?: return@transaction publicBatchNotFound("批次创建失败")
|
||||||
|
TraceabilityPublicBatchOperationResult(
|
||||||
|
message = if (overwritten) "批次已覆盖并发布" else "批次已创建并发布",
|
||||||
|
data = batch.toPublicBatchResponse(overwritten),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPublicBatchUrl(batchId: UUID): TraceabilityPublicBatchResponse? = transaction {
|
||||||
|
getBatch(batchId)?.toPublicBatchResponse()
|
||||||
|
}
|
||||||
|
|
||||||
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
|
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
|
||||||
val now = timestampLiteral(nowInstant())
|
val now = timestampLiteral(nowInstant())
|
||||||
val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
|
val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
|
||||||
@@ -894,6 +1037,290 @@ object TraceabilityDao {
|
|||||||
listFeedback().firstOrNull { it.id == feedbackId.toString() }
|
listFeedback().firstOrNull { it.id == feedbackId.toString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun preparePublicBatchSteps(
|
||||||
|
template: TraceTemplateDetailResponse,
|
||||||
|
request: CreateTraceabilityPublicBatchRequest,
|
||||||
|
now: Instant,
|
||||||
|
): PreparedPublicBatchStepsResult {
|
||||||
|
val requestByNodeId = mutableMapOf<String, TraceabilityPublicBatchStepRequest>()
|
||||||
|
request.steps.forEachIndexed { index, stepRequest ->
|
||||||
|
val node = resolveTemplateNode(template.nodes, stepRequest)
|
||||||
|
?: return PreparedPublicBatchStepsResult(
|
||||||
|
error = publicBatchBadRequest("steps[$index] 未匹配到模板节点,请传 templateNodeId 或唯一 nodeName"),
|
||||||
|
)
|
||||||
|
if (requestByNodeId.put(node.id, stepRequest) != null) {
|
||||||
|
return PreparedPublicBatchStepsResult(
|
||||||
|
error = publicBatchBadRequest("节点“${node.name}”重复传入"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val steps = template.nodes.map { node ->
|
||||||
|
val stepRequest = requestByNodeId[node.id]
|
||||||
|
val values = normalizePublicBatchStepValues(node, stepRequest?.values ?: JsonObject(emptyMap()))
|
||||||
|
if (values.error.isNotBlank()) {
|
||||||
|
return PreparedPublicBatchStepsResult(error = publicBatchBadRequest(values.error))
|
||||||
|
}
|
||||||
|
val completedAt = parsePublicBatchCompletedAt(stepRequest?.completedAt ?: request.completedAt, now)
|
||||||
|
?: return PreparedPublicBatchStepsResult(
|
||||||
|
error = publicBatchBadRequest("节点“${node.name}”的 completedAt 格式无效,请使用 ISO-8601 时间"),
|
||||||
|
)
|
||||||
|
PreparedPublicBatchStep(
|
||||||
|
node = node,
|
||||||
|
values = values.value as JsonObject,
|
||||||
|
operatorName = stepRequest?.operatorName?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
?: request.operatorName.trim(),
|
||||||
|
completedAt = completedAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return PreparedPublicBatchStepsResult(steps = steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveTemplateNode(
|
||||||
|
nodes: List<TraceTemplateNodeResponse>,
|
||||||
|
request: TraceabilityPublicBatchStepRequest,
|
||||||
|
): TraceTemplateNodeResponse? {
|
||||||
|
val nodeId = request.templateNodeId?.trim().orEmpty()
|
||||||
|
if (nodeId.isNotBlank()) {
|
||||||
|
return nodes.find { it.id == nodeId }
|
||||||
|
}
|
||||||
|
|
||||||
|
val nodeName = request.nodeName?.trim().orEmpty()
|
||||||
|
if (nodeName.isBlank()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val matched = nodes.filter { it.name == nodeName }
|
||||||
|
return matched.singleOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePublicBatchStepValues(
|
||||||
|
node: TraceTemplateNodeResponse,
|
||||||
|
providedValues: JsonObject,
|
||||||
|
): FieldNormalizationResult {
|
||||||
|
val fieldByKey = node.fields.associateBy { it.key }
|
||||||
|
if (fieldByKey.size != node.fields.size) {
|
||||||
|
return FieldNormalizationResult(error = "节点“${node.name}”存在重复字段 Key,无法通过接口写入")
|
||||||
|
}
|
||||||
|
|
||||||
|
providedValues.keys.forEach { key ->
|
||||||
|
if (!fieldByKey.containsKey(key)) {
|
||||||
|
return FieldNormalizationResult(error = "节点“${node.name}”不包含字段“$key”")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val values = linkedMapOf<String, JsonElement>()
|
||||||
|
node.fields.forEach { field ->
|
||||||
|
val hasProvidedValue = providedValues.containsKey(field.key)
|
||||||
|
val defaultValue = field.defaultValue ?: JsonNull
|
||||||
|
val normalized = if (field.fixedPreset) {
|
||||||
|
val normalizedDefault = normalizePublicFieldValue(field, defaultValue)
|
||||||
|
if (normalizedDefault.error.isNotBlank()) {
|
||||||
|
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”的固定预设值无效:${normalizedDefault.error}")
|
||||||
|
}
|
||||||
|
if (hasProvidedValue) {
|
||||||
|
val normalizedProvided = normalizePublicFieldValue(field, providedValues[field.key] ?: JsonNull)
|
||||||
|
if (normalizedProvided.error.isNotBlank()) {
|
||||||
|
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”无效:${normalizedProvided.error}")
|
||||||
|
}
|
||||||
|
if (normalizedProvided.value != normalizedDefault.value) {
|
||||||
|
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”为固定预设值,不允许覆盖")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalizedDefault
|
||||||
|
} else {
|
||||||
|
normalizePublicFieldValue(field, providedValues[field.key] ?: defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.error.isNotBlank()) {
|
||||||
|
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”无效:${normalized.error}")
|
||||||
|
}
|
||||||
|
if (field.required && isBlankJsonValue(normalized.value)) {
|
||||||
|
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”为必填")
|
||||||
|
}
|
||||||
|
values[field.key] = normalized.value ?: JsonNull
|
||||||
|
}
|
||||||
|
return FieldNormalizationResult(value = JsonObject(values))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizePublicFieldValue(
|
||||||
|
field: TraceFieldDefinitionResponse,
|
||||||
|
value: JsonElement,
|
||||||
|
): FieldNormalizationResult {
|
||||||
|
if (value == JsonNull) {
|
||||||
|
return when (field.type) {
|
||||||
|
"multi_select" -> FieldNormalizationResult(JsonArray(emptyList()))
|
||||||
|
else -> FieldNormalizationResult(JsonNull)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (field.type) {
|
||||||
|
"integer" -> normalizeIntegerValue(value)
|
||||||
|
"decimal" -> normalizeDecimalValue(value)
|
||||||
|
"date" -> normalizeDateValue(value)
|
||||||
|
"datetime" -> normalizeDateTimeValue(value)
|
||||||
|
"coordinate" -> normalizeCoordinateValue(value)
|
||||||
|
"select" -> normalizeSelectValue(field, value)
|
||||||
|
"multi_select" -> normalizeMultiSelectValue(field, value)
|
||||||
|
"image" -> normalizeImageValue(value)
|
||||||
|
"link", "video_url" -> normalizeUrlValue(value)
|
||||||
|
"json" -> FieldNormalizationResult(value)
|
||||||
|
else -> normalizeTextValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeIntegerValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是整数")
|
||||||
|
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
|
||||||
|
val parsed = text.toLongOrNull() ?: return FieldNormalizationResult(error = "必须是整数")
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeDecimalValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是小数")
|
||||||
|
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
|
||||||
|
val parsed = text.toDoubleOrNull() ?: return FieldNormalizationResult(error = "必须是小数")
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeDateValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是日期字符串")
|
||||||
|
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
|
||||||
|
val parsed = runCatching { LocalDate.parse(text) }.getOrNull()
|
||||||
|
?: return FieldNormalizationResult(error = "日期格式应为 yyyy-MM-dd")
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(parsed.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeDateTimeValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是日期时间字符串")
|
||||||
|
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
|
||||||
|
val localDateTime = runCatching { LocalDateTime.parse(text.replace(' ', 'T')) }.getOrNull()
|
||||||
|
if (localDateTime != null) {
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(localDateTime.format(dateTimeFormatter)))
|
||||||
|
}
|
||||||
|
val instant = runCatching { Instant.parse(text) }.getOrNull()
|
||||||
|
?: return FieldNormalizationResult(error = "日期时间格式应为 yyyy-MM-dd HH:mm:ss 或 ISO-8601")
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(instant.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeCoordinateValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
val coordinate = value as? JsonObject ?: return FieldNormalizationResult(error = "必须是坐标对象")
|
||||||
|
val lng = primitiveText(coordinate["lng"] ?: JsonNull)?.toDoubleOrNull()
|
||||||
|
val lat = primitiveText(coordinate["lat"] ?: JsonNull)?.toDoubleOrNull()
|
||||||
|
if (lng == null || lat == null) {
|
||||||
|
return FieldNormalizationResult(error = "坐标必须包含数值 lng 和 lat")
|
||||||
|
}
|
||||||
|
return FieldNormalizationResult(
|
||||||
|
buildJsonObject {
|
||||||
|
put("lng", lng)
|
||||||
|
put("lat", lat)
|
||||||
|
primitiveText(coordinate["address"] ?: JsonNull)?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
put("address", it)
|
||||||
|
}
|
||||||
|
primitiveText(coordinate["source"] ?: JsonNull)?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
put("source", it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeSelectValue(
|
||||||
|
field: TraceFieldDefinitionResponse,
|
||||||
|
value: JsonElement,
|
||||||
|
): FieldNormalizationResult {
|
||||||
|
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是字符串")
|
||||||
|
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
|
||||||
|
if (field.options.isNotEmpty() && text !in field.options) {
|
||||||
|
return FieldNormalizationResult(error = "必须是可选项之一:${field.options.joinToString("、")}")
|
||||||
|
}
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeMultiSelectValue(
|
||||||
|
field: TraceFieldDefinitionResponse,
|
||||||
|
value: JsonElement,
|
||||||
|
): FieldNormalizationResult {
|
||||||
|
val array = value as? JsonArray ?: return FieldNormalizationResult(error = "必须是字符串数组")
|
||||||
|
val items = array.mapIndexed { index, item ->
|
||||||
|
primitiveText(item)?.takeIf { it.isNotBlank() }
|
||||||
|
?: return FieldNormalizationResult(error = "第 ${index + 1} 项必须是非空字符串")
|
||||||
|
}
|
||||||
|
val invalid = if (field.options.isEmpty()) emptyList() else items.filter { it !in field.options }
|
||||||
|
if (invalid.isNotEmpty()) {
|
||||||
|
return FieldNormalizationResult(error = "包含非法选项:${invalid.joinToString("、")}")
|
||||||
|
}
|
||||||
|
return FieldNormalizationResult(JsonArray(items.map(::JsonPrimitive)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeImageValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
if (value is JsonPrimitive) {
|
||||||
|
val text = value.contentOrNull?.trim().orEmpty()
|
||||||
|
return FieldNormalizationResult(if (text.isBlank()) JsonNull else JsonPrimitive(text))
|
||||||
|
}
|
||||||
|
val image = value as? JsonObject ?: return FieldNormalizationResult(error = "必须是图片地址字符串或 OSS 对象")
|
||||||
|
val objectName = primitiveText(image["objectName"] ?: JsonNull).orEmpty()
|
||||||
|
if (objectName.isBlank()) {
|
||||||
|
return FieldNormalizationResult(error = "OSS 图片对象必须包含 objectName")
|
||||||
|
}
|
||||||
|
val bucketName = primitiveText(image["bucketName"] ?: JsonNull).orEmpty().ifBlank { OSSUtils.defaultBucket() }
|
||||||
|
return FieldNormalizationResult(
|
||||||
|
buildJsonObject {
|
||||||
|
put("bucketName", bucketName)
|
||||||
|
put("objectName", objectName)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeUrlValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是链接字符串")
|
||||||
|
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
|
||||||
|
if (!text.startsWith("http://") && !text.startsWith("https://")) {
|
||||||
|
return FieldNormalizationResult(error = "链接必须以 http:// 或 https:// 开头")
|
||||||
|
}
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeTextValue(value: JsonElement): FieldNormalizationResult {
|
||||||
|
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是字符串")
|
||||||
|
return FieldNormalizationResult(JsonPrimitive(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun primitiveText(value: JsonElement): String? {
|
||||||
|
if (value == JsonNull) return null
|
||||||
|
return (value as? JsonPrimitive)?.contentOrNull?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBlankJsonValue(value: JsonElement?): Boolean = when (value) {
|
||||||
|
null, JsonNull -> true
|
||||||
|
is JsonPrimitive -> value.contentOrNull?.trim().isNullOrEmpty()
|
||||||
|
is JsonArray -> value.isEmpty()
|
||||||
|
is JsonObject -> value.isEmpty()
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parsePublicBatchCompletedAt(raw: String?, fallback: Instant): Instant? {
|
||||||
|
val text = raw?.trim().orEmpty()
|
||||||
|
if (text.isBlank()) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return runCatching { Instant.parse(text) }.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publicBatchBadRequest(message: String): TraceabilityPublicBatchOperationResult =
|
||||||
|
TraceabilityPublicBatchOperationResult(message = message, error = TraceabilityPublicBatchError.BAD_REQUEST)
|
||||||
|
|
||||||
|
private fun publicBatchNotFound(message: String): TraceabilityPublicBatchOperationResult =
|
||||||
|
TraceabilityPublicBatchOperationResult(message = message, error = TraceabilityPublicBatchError.NOT_FOUND)
|
||||||
|
|
||||||
|
private fun TraceBatchDetailResponse.toPublicBatchResponse(overwritten: Boolean = false): TraceabilityPublicBatchResponse =
|
||||||
|
TraceabilityPublicBatchResponse(
|
||||||
|
batchId = id,
|
||||||
|
batchCode = batchCode,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
status = status,
|
||||||
|
publishedAt = publishedAt,
|
||||||
|
overwritten = overwritten,
|
||||||
|
)
|
||||||
|
|
||||||
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
|
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
|
||||||
return TraceabilityTemplateNodesTable.selectAll()
|
return TraceabilityTemplateNodesTable.selectAll()
|
||||||
.where { TraceabilityTemplateNodesTable.templateId eq templateId }
|
.where { TraceabilityTemplateNodesTable.templateId eq templateId }
|
||||||
|
|||||||
@@ -456,6 +456,15 @@ async function copyTemplate(id: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyTemplateId(id: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(id);
|
||||||
|
message.success('模板ID已复制');
|
||||||
|
} catch {
|
||||||
|
message.warning('当前浏览器不支持自动复制,请手动复制模板ID');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createLibraryNode(category: 'business' | 'public') {
|
async function createLibraryNode(category: 'business' | 'public') {
|
||||||
const node: EditableTemplateNode = {
|
const node: EditableTemplateNode = {
|
||||||
...createEmptyNode(category),
|
...createEmptyNode(category),
|
||||||
@@ -1040,6 +1049,13 @@ onMounted(async () => {
|
|||||||
<span>{{ item.nodeCount }} 节点</span>
|
<span>{{ item.nodeCount }} 节点</span>
|
||||||
<span>{{ item.batchCount }} 批次</span>
|
<span>{{ item.batchCount }} 批次</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="template-card__id">
|
||||||
|
<span>模板ID</span>
|
||||||
|
<code :title="item.id">{{ item.id }}</code>
|
||||||
|
<Button size="small" type="link" @click.stop="copyTemplateId(item.id)">
|
||||||
|
复制ID
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p class="template-card__remark">
|
<p class="template-card__remark">
|
||||||
{{ item.remark || '暂无备注' }}
|
{{ item.remark || '暂无备注' }}
|
||||||
</p>
|
</p>
|
||||||
@@ -2816,6 +2832,34 @@ onMounted(async () => {
|
|||||||
margin: 8px 0 6px;
|
margin: 8px 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-card__id {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #dbe7ff;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f3f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card__id code {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #1f4fd6;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-card__id :deep(.ant-btn) {
|
||||||
|
height: auto;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.template-card__remark {
|
.template-card__remark {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
color: #556070;
|
color: #556070;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# push_docker.ps1
|
# push_docker.ps1
|
||||||
|
|
||||||
# Set version
|
# Set version
|
||||||
$env:VERSION = "1.5.6"
|
$env:VERSION = "1.5.7"
|
||||||
|
|
||||||
# Docker registry/repository
|
# Docker registry/repository
|
||||||
$registry = "docker.bbitcn.net/bbit_ai/ce_vue"
|
$registry = "docker.bbitcn.net/bbit_ai/ce_vue"
|
||||||
|
|||||||
Reference in New Issue
Block a user