修复溯源模块大量问题

This commit is contained in:
BBIT-Kai
2026-04-14 10:10:52 +08:00
parent 0a43f5e4b9
commit 1c68762421
26 changed files with 3413 additions and 463 deletions
@@ -75,5 +75,5 @@ fun Application.module() {
VideoAnalyticsJetson()
// 业务-图片分析
ImageAnalytics()
Traceability()
Traceability(appConfig)
}
@@ -2,7 +2,10 @@ package ink.snowflake.server.controller
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
import ink.snowflake.server.model.request.TraceabilityFileAssetDeleteRequest
import ink.snowflake.server.model.request.TraceabilityOssDeleteRequest
import ink.snowflake.server.model.request.TraceabilityOssMoveRequest
import ink.snowflake.server.model.request.TraceabilityOssPresignRequest
@@ -13,6 +16,7 @@ import ink.snowflake.server.model.response.BaseResponse
import ink.snowflake.server.model.response.TraceBatchStepResponse
import ink.snowflake.server.model.response.TraceabilityOssFileResponse
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.OSSUtils
import ink.snowflake.server.utils.dao.TraceabilityDao
import io.ktor.http.ContentType
@@ -35,7 +39,58 @@ import kotlinx.serialization.json.JsonObject
import java.util.Locale
import java.util.UUID
fun Application.Traceability() {
private fun parseUuidOrNull(raw: String?): UUID? = runCatching {
raw?.let(UUID::fromString)
}.getOrNull()
private suspend fun handleSaveNodeLibrary(call: io.ktor.server.application.ApplicationCall, id: UUID?) {
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "节点库ID无效", data = null))
return
}
val request = call.receive<SaveTraceNodeLibraryRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveNodeLibrary(id, request)))
}
private suspend fun handleSaveTemplate(call: io.ktor.server.application.ApplicationCall, id: UUID?) {
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return
}
val request = call.receive<SaveTraceTemplateRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(id, request)))
}
private suspend fun handleUpdateBatchBase(call: io.ktor.server.application.ApplicationCall, id: UUID?) {
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return
}
val request = call.receive<UpdateTraceBatchBaseRequest>()
val data = TraceabilityDao.updateBatchBase(id, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
return
}
call.respond(BaseResponse(data = data))
}
private suspend fun handleUpdateBatchStep(call: io.ktor.server.application.ApplicationCall, id: UUID?, stepId: UUID?) {
if (id == null || stepId == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "步骤ID无效", data = null))
return
}
val request = call.receive<UpdateTraceBatchStepRequest>()
val data = TraceabilityDao.updateBatchStep(id, stepId, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "步骤不存在", data = null))
return
}
call.respond(BaseResponse(data = data))
}
fun Application.Traceability(config: AppConfig) {
TraceabilityDao.init(config)
TraceabilityDao.initSchema()
routing {
@@ -43,6 +98,30 @@ fun Application.Traceability() {
get("/overview") {
call.respond(BaseResponse(data = TraceabilityDao.getOverview()))
}
route("/node-library") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listNodeLibrary()))
}
post {
val request = call.receive<SaveTraceNodeLibraryRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveNodeLibrary(null, request)))
}
put("/{id}") {
handleSaveNodeLibrary(call, parseUuidOrNull(call.parameters["id"]))
}
post("/{id}") {
handleSaveNodeLibrary(call, parseUuidOrNull(call.parameters["id"]))
}
delete("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "节点库ID无效", data = null))
return@delete
}
val deleted = TraceabilityDao.deleteNodeLibrary(id)
call.respond(BaseResponse(status = deleted, message = if (deleted) "节点库节点已删除" else "节点库节点不存在", data = deleted))
}
}
route("/templates") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listTemplates()))
@@ -52,7 +131,7 @@ fun Application.Traceability() {
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(null, request)))
}
get("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
val data = id?.let(TraceabilityDao::getTemplate)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
@@ -61,16 +140,13 @@ fun Application.Traceability() {
call.respond(BaseResponse(data = data))
}
put("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@put
}
val request = call.receive<SaveTraceTemplateRequest>()
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(id, request)))
handleSaveTemplate(call, parseUuidOrNull(call.parameters["id"]))
}
post("/{id}") {
handleSaveTemplate(call, parseUuidOrNull(call.parameters["id"]))
}
delete("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@delete
@@ -80,16 +156,89 @@ fun Application.Traceability() {
}
}
route("/previews") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listPreviewPages()))
}
post {
val request = call.receive<SaveTracePreviewPageRequest>()
call.respond(BaseResponse(data = TraceabilityDao.savePreviewPage(null, request)))
}
get("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
val data = id?.let(TraceabilityDao::getPreviewPage)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "预演页不存在", data = null))
return@get
}
call.respond(BaseResponse(data = data))
}
put("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@put
}
val request = call.receive<SaveTracePreviewPageRequest>()
call.respond(BaseResponse(data = TraceabilityDao.savePreviewPage(id, request)))
}
post("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@post
}
val request = call.receive<SaveTracePreviewPageRequest>()
call.respond(BaseResponse(data = TraceabilityDao.savePreviewPage(id, request)))
}
delete("/{id}") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@delete
}
val deleted = TraceabilityDao.deletePreviewPage(id)
call.respond(BaseResponse(status = deleted, message = if (deleted) "预演页已删除" else "预演页不存在", data = deleted))
}
post("/{id}/sync-template") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "预演页ID无效", data = null))
return@post
}
val data = TraceabilityDao.syncPreviewToTemplate(id)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "预演页不存在", data = null))
return@post
}
call.respond(BaseResponse(message = "已同步为新模板", data = data))
}
}
route("/batches") {
get {
call.respond(BaseResponse(data = TraceabilityDao.listBatches()))
}
post {
val request = call.receive<CreateTraceBatchRequest>()
call.respond(BaseResponse(data = TraceabilityDao.createBatch(request)))
val templateId = parseUuidOrNull(request.templateId)
if (templateId == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
return@post
}
if (TraceabilityDao.getTemplate(templateId) == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
return@post
}
val data = TraceabilityDao.createBatch(request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
return@post
}
call.respond(BaseResponse(data = data))
}
delete("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@delete
@@ -98,7 +247,7 @@ fun Application.Traceability() {
call.respond(BaseResponse(status = deleted, message = if (deleted) "批次已删除" else "批次不存在", data = deleted))
}
get("/{id}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
val data = id?.let(TraceabilityDao::getBatch)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
@@ -107,36 +256,27 @@ fun Application.Traceability() {
call.respond(BaseResponse(data = data))
}
put("/{id}/base") {
val id = call.parameters["id"]?.let(UUID::fromString)
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@put
}
val request = call.receive<UpdateTraceBatchBaseRequest>()
val data = TraceabilityDao.updateBatchBase(id, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
return@put
}
call.respond(BaseResponse(data = data))
handleUpdateBatchBase(call, parseUuidOrNull(call.parameters["id"]))
}
post("/{id}/base") {
handleUpdateBatchBase(call, parseUuidOrNull(call.parameters["id"]))
}
put("/{id}/steps/{stepId}") {
val id = call.parameters["id"]?.let(UUID::fromString)
val stepId = call.parameters["stepId"]?.let(UUID::fromString)
if (id == null || stepId == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "步骤ID无效", data = null))
return@put
}
val request = call.receive<UpdateTraceBatchStepRequest>()
val data = TraceabilityDao.updateBatchStep(id, stepId, request)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "步骤不存在", data = null))
return@put
}
call.respond(BaseResponse(data = data))
handleUpdateBatchStep(
call,
parseUuidOrNull(call.parameters["id"]),
parseUuidOrNull(call.parameters["stepId"]),
)
}
post("/{id}/steps/{stepId}") {
handleUpdateBatchStep(
call,
parseUuidOrNull(call.parameters["id"]),
parseUuidOrNull(call.parameters["stepId"]),
)
}
post("/{id}/publish") {
val id = call.parameters["id"]?.let(UUID::fromString)
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@post
@@ -156,7 +296,12 @@ fun Application.Traceability() {
}
post {
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
call.respond(BaseResponse(message = "反馈已提交", data = TraceabilityDao.submitFeedback(request)))
val data = TraceabilityDao.submitFeedback(request)
if (data == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次不存在或批次参数无效", data = null))
return@post
}
call.respond(BaseResponse(message = "反馈已提交", data = data))
}
}
route("/public") {
@@ -170,9 +315,42 @@ fun Application.Traceability() {
}
call.respond(BaseResponse(data = data))
}
get("/preview/by-code/{code}") {
val code = call.parameters["code"] ?: ""
val data = TraceabilityDao.getPublicDetailByCode(
batchCode = code,
increaseScan = false,
onlyPublished = false,
)
if (data == null) {
call.respond(
HttpStatusCode.NotFound,
BaseResponse(status = false, message = "未找到对应批次", data = null),
)
return@get
}
call.respond(BaseResponse(data = data))
}
get("/preview-page/by-code/{code}") {
val code = call.parameters["code"] ?: ""
val data = TraceabilityDao.getPreviewPublicDetailByCode(code)
if (data == null) {
call.respond(
HttpStatusCode.NotFound,
BaseResponse(status = false, message = "未找到对应预演页", data = null),
)
return@get
}
call.respond(BaseResponse(data = data))
}
post("/feedback") {
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
call.respond(BaseResponse(message = "感谢反馈,我们会尽快处理", data = TraceabilityDao.submitFeedback(request)))
val data = TraceabilityDao.submitFeedback(request)
if (data == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "未找到对应批次,无法提交反馈", data = null))
return@post
}
call.respond(BaseResponse(message = "感谢反馈,我们会尽快处理", data = data))
}
get("/page/{code}") {
val code = call.parameters["code"] ?: ""
@@ -191,6 +369,7 @@ fun Application.Traceability() {
var bucketName = OSSUtils.defaultBucket()
var objectDir = "traceability/images"
var objectName = ""
var assetType = ""
var response: TraceabilityOssFileResponse? = null
multipart.forEachPart { part ->
@@ -200,6 +379,7 @@ fun Application.Traceability() {
"bucketName" -> bucketName = part.value.ifBlank { OSSUtils.defaultBucket() }
"objectDir" -> objectDir = part.value.ifBlank { "traceability/images" }
"objectName" -> objectName = part.value
"assetType" -> assetType = part.value.trim()
}
}
@@ -223,6 +403,16 @@ fun Application.Traceability() {
fileName = fileName,
size = bytes.size.toLong(),
)
if (assetType.isNotBlank()) {
TraceabilityDao.recordFileAsset(
assetType = assetType,
bucketName = bucketName,
objectName = finalObjectName,
fileName = fileName,
contentType = contentType,
size = bytes.size.toLong(),
)
}
}
else -> {}
@@ -240,6 +430,39 @@ fun Application.Traceability() {
call.respond(BaseResponse(message = "图片上传成功", data = response))
}
get("/history") {
val assetType = call.request.queryParameters["assetType"]?.trim().orEmpty()
val limit = call.request.queryParameters["limit"]?.toIntOrNull()?.coerceIn(1, 100) ?: 24
if (assetType.isBlank()) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse(status = false, message = "assetType 不能为空", data = null),
)
return@get
}
call.respond(BaseResponse(data = TraceabilityDao.listFileAssets(assetType, limit)))
}
post("/history/delete") {
val request = call.receive<TraceabilityFileAssetDeleteRequest>()
val assetId = parseUuidOrNull(request.id)
if (assetId == null) {
call.respond(
HttpStatusCode.BadRequest,
BaseResponse(status = false, message = "历史文件ID无效", data = false),
)
return@post
}
val (status, messageText) = TraceabilityDao.deleteFileAsset(assetId)
call.respond(
if (status) {
BaseResponse(message = messageText, data = true)
} else {
BaseResponse(status = false, message = messageText, data = false)
},
)
}
post("/presigned-put") {
val request = call.receive<TraceabilityOssPresignRequest>()
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
@@ -324,7 +547,8 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
val batch = detail.batch
val publicCards = detail.publicSections.joinToString("") { renderSectionCard(it) }
val timelineCards = detail.businessSections.joinToString("") { renderTimelineCard(it) }
val cover = batch.coverImage.takeIf { it.isNotBlank() }
val cover = batch.coverImagePreviewUrl.takeIf { it.isNotBlank() }
?: batch.coverImage.takeIf { it.isNotBlank() }
?: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1400&q=80"
return """
@@ -405,18 +629,18 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
<div class="timeline">$timelineCards</div>
</section>
<section class="section">
<div class="section-head"><div><h2>投诉与建议</h2><p>如果你发现信息异常、质量问题,或有优化建议,可以直接提交。</p></div></div>
<div class="section-head"><div><h2>反馈与投诉</h2><p>如果你发现信息异常、质量问题,或有优化建议,可以直接提交。</p></div></div>
<div class="feedback-grid">
<div class="feedback">
<form id="feedback-form">
<label>反馈类型</label>
<select name="type"><option value="complaint">投诉</option><option value="suggestion">建议</option><option value="consult">咨询</option></select>
<label>满意度</label>
<select name="rating"><option value="5">5 分</option><option value="4">4 分</option><option value="3">3 分</option><option value="2">2 分</option><option value="1">1 分</option></select>
<label>联系方式</label>
<input name="contact" placeholder="手机号、邮箱或微信(选填)" />
<label>反馈内容</label>
<textarea name="content" placeholder="请描述你的问题或建议"></textarea>
<label>满意度</label>
<select name="rating"><option value="5">5 分</option><option value="4">4 分</option><option value="3">3 分</option><option value="2">2 分</option><option value="1">1 分</option></select>
<button type="submit">提交反馈</button>
</form>
</div>
@@ -450,15 +674,21 @@ private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): St
}
private fun renderSectionCard(step: TraceBatchStepResponse): String =
"""<article class="section-card"><h3>${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p><div class="kv-grid">${renderValueCards(step.values)}</div></article>"""
"""<article class="section-card"><h3>${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p><div class="kv-grid">${renderValueCards(step)}</div></article>"""
private fun renderTimelineCard(step: TraceBatchStepResponse): String {
val time = step.completedAt.ifBlank { "待补充" }
return """<div class="timeline-item"><div class="timeline-rail"><span class="dot"></span><span class="line"></span></div><div class="timeline-card"><div class="timeline-meta"><div><h3 style="margin:0;">${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p></div><span class="tag">${escapeHtml(step.status)} · ${escapeHtml(time)}</span></div><div class="kv-grid">${renderValueCards(step.values)}</div></div></div>"""
return """<div class="timeline-item"><div class="timeline-rail"><span class="dot"></span><span class="line"></span></div><div class="timeline-card"><div class="timeline-meta"><div><h3 style="margin:0;">${escapeHtml(step.name)}</h3><p class="muted">${escapeHtml(step.description)}</p></div></div><div class="kv-grid">${renderValueCards(step)}</div></div></div>"""
}
private fun renderValueCards(values: JsonObject): String = values.entries.joinToString("") { (key, value) ->
"""<div class="kv"><span>${escapeHtml(key)}</span><strong>${escapeHtml(formatJsonValue(value))}</strong></div>"""
private fun renderValueCards(step: TraceBatchStepResponse): String = step.values.entries.joinToString("") { (key, value) ->
val field = step.fields.find { it.key == key }
val label = field?.label ?: key
val imageUrl = step.valuePreviewUrls[key].orEmpty()
if ((field?.type ?: "string") == "image" && imageUrl.isNotBlank()) {
"""<div class="kv"><span>${escapeHtml(label)}</span><img src="${escapeHtml(imageUrl)}" alt="${escapeHtml(label)}" style="display:block;width:100%;max-height:220px;margin-top:8px;border:1px solid #dbe3f0;border-radius:14px;object-fit:cover;background:#fff;" /></div>"""
} else {
"""<div class="kv"><span>${escapeHtml(label)}</span><strong>${escapeHtml(formatJsonValue(value))}</strong></div>"""
}
}
private fun formatJsonValue(value: JsonElement): String = when (value) {
@@ -28,6 +28,41 @@ object TraceabilityTemplateNodesTable : UUIDTable("traceability_template_nodes")
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityPreviewPagesTable : UUIDTable("traceability_preview_pages") {
val name = varchar("name", 120)
val previewCode = varchar("preview_code", 120).uniqueIndex()
val description = text("description").default("")
val productName = varchar("product_name", 120).default("")
val coverImage = text("cover_image").default("")
val themeColor = varchar("theme_color", 20).default("#1f4fd6")
val tagsJson = text("tags_json").default("[]")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityPreviewNodesTable : UUIDTable("traceability_preview_nodes") {
val previewPageId = reference("preview_page_id", TraceabilityPreviewPagesTable)
val sort = integer("sort").default(0)
val category = varchar("category", 32).default("business")
val name = varchar("name", 120)
val description = text("description").default("")
val consumerVisible = bool("consumer_visible").default(true)
val fieldsJson = text("fields_json").default("[]")
val valuesJson = text("values_json").default("{}")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityNodeLibraryTable : UUIDTable("traceability_node_library") {
val category = varchar("category", 32).default("business")
val name = varchar("name", 120)
val description = text("description").default("")
val consumerVisible = bool("consumer_visible").default(true)
val fieldsJson = text("fields_json")
val createdAt = timestamp("created_at").nullable()
val updatedAt = timestamp("updated_at").nullable()
}
object TraceabilityBatchesTable : UUIDTable("traceability_batches") {
val templateId = reference("template_id", TraceabilityTemplatesTable)
val batchName = varchar("batch_name", 150)
@@ -55,6 +90,7 @@ object TraceabilityBatchStepsTable : UUIDTable("traceability_batch_steps") {
val consumerVisible = bool("consumer_visible").default(true)
val status = varchar("status", 32).default("pending")
val operatorName = varchar("operator_name", 80).default("")
val fieldsJson = text("fields_json").default("[]")
val valuesJson = text("values_json").default("{}")
val completedAt = timestamp("completed_at").nullable()
val createdAt = timestamp("created_at").nullable()
@@ -70,3 +106,13 @@ object TraceabilityFeedbackTable : UUIDTable("traceability_feedback") {
val rating = integer("rating").default(5)
val createdAt = timestamp("created_at").nullable()
}
object TraceabilityFileAssetsTable : UUIDTable("traceability_file_assets") {
val assetType = varchar("asset_type", 32).default("general")
val bucketName = varchar("bucket_name", 120)
val objectName = text("object_name")
val fileName = varchar("file_name", 255).default("")
val contentType = varchar("content_type", 120).default("")
val size = long("size").default(0)
val createdAt = timestamp("created_at").nullable()
}
@@ -5,6 +5,12 @@ import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import java.util.UUID
@Serializable
data class TraceFieldStyleRequest(
val bold: Boolean = false,
val color: String = "",
)
@Serializable
data class TraceFieldDefinitionRequest(
val key: String,
@@ -12,9 +18,11 @@ data class TraceFieldDefinitionRequest(
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val fixedPreset: Boolean = false,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val options: List<String> = emptyList(),
val fieldStyle: TraceFieldStyleRequest = TraceFieldStyleRequest(),
)
@Serializable
@@ -28,6 +36,17 @@ data class TraceTemplateNodeRequest(
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
)
@Serializable
data class TracePreviewNodeRequest(
val id: String? = null,
val category: String = "business",
val name: String,
val description: String = "",
val consumerVisible: Boolean = true,
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
val values: JsonObject = JsonObject(emptyMap()),
)
@Serializable
data class SaveTraceTemplateRequest(
val name: String,
@@ -40,6 +59,26 @@ data class SaveTraceTemplateRequest(
val nodes: List<TraceTemplateNodeRequest> = emptyList(),
)
@Serializable
data class SaveTracePreviewPageRequest(
val name: String,
val description: String = "",
val productName: String = "",
val coverImage: String = "",
val themeColor: String = "#1f4fd6",
val tags: List<String> = emptyList(),
val nodes: List<TracePreviewNodeRequest> = emptyList(),
)
@Serializable
data class SaveTraceNodeLibraryRequest(
val category: String = "business",
val name: String,
val description: String = "",
val consumerVisible: Boolean = true,
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
)
@Serializable
data class CreateTraceBatchRequest(
val templateId: String,
@@ -109,4 +148,9 @@ data class TraceabilityOssDeleteRequest(
val objectName: String,
)
@Serializable
data class TraceabilityFileAssetDeleteRequest(
val id: String,
)
fun CreateTraceBatchRequest.templateUuid(): UUID = UUID.fromString(templateId)
@@ -13,6 +13,12 @@ data class TraceabilityOverviewResponse(
val totalScans: Int,
)
@Serializable
data class TraceFieldStyleResponse(
val bold: Boolean = false,
val color: String = "",
)
@Serializable
data class TraceFieldDefinitionResponse(
val key: String,
@@ -20,9 +26,12 @@ data class TraceFieldDefinitionResponse(
val type: String = "string",
val required: Boolean = false,
val visible: Boolean = true,
val fixedPreset: Boolean = false,
val placeholder: String = "",
val defaultValue: JsonElement? = null,
val defaultPreviewUrl: String? = null,
val options: List<String> = emptyList(),
val fieldStyle: TraceFieldStyleResponse = TraceFieldStyleResponse(),
)
@Serializable
@@ -37,6 +46,17 @@ data class TraceTemplateNodeResponse(
val fields: List<TraceFieldDefinitionResponse>,
)
@Serializable
data class TraceNodeLibraryResponse(
val id: String,
val category: String,
val name: String,
val description: String,
val consumerVisible: Boolean,
val fields: List<TraceFieldDefinitionResponse>,
val updatedAt: String,
)
@Serializable
data class TraceTemplateSummaryResponse(
val id: String,
@@ -45,6 +65,7 @@ data class TraceTemplateSummaryResponse(
val productName: String,
val industryName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val status: String,
val nodeCount: Int,
@@ -60,12 +81,57 @@ data class TraceTemplateDetailResponse(
val productName: String,
val industryName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val status: String,
val nodes: List<TraceTemplateNodeResponse>,
val updatedAt: String,
)
@Serializable
data class TracePreviewNodeResponse(
val id: String,
val sort: Int,
val category: String,
val name: String,
val description: String,
val consumerVisible: Boolean,
val values: JsonObject,
val valuePreviewUrls: Map<String, String> = emptyMap(),
val fields: List<TraceFieldDefinitionResponse>,
)
@Serializable
data class TracePreviewPageSummaryResponse(
val id: String,
val name: String,
val previewCode: String,
val description: String,
val productName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val tags: List<String>,
val publicUrl: String,
val updatedAt: String,
)
@Serializable
data class TracePreviewPageDetailResponse(
val id: String,
val name: String,
val previewCode: String,
val description: String,
val productName: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val themeColor: String,
val tags: List<String>,
val publicUrl: String,
val nodes: List<TracePreviewNodeResponse>,
val updatedAt: String,
)
@Serializable
data class TraceBatchStepResponse(
val id: String,
@@ -79,6 +145,7 @@ data class TraceBatchStepResponse(
val status: String,
val operatorName: String,
val values: JsonObject,
val valuePreviewUrls: Map<String, String> = emptyMap(),
val completedAt: String = "",
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
)
@@ -93,6 +160,7 @@ data class TraceBatchSummaryResponse(
val productName: String,
val summary: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val tags: List<String>,
val status: String,
val currentStep: Int,
@@ -111,6 +179,7 @@ data class TraceBatchDetailResponse(
val productName: String,
val summary: String,
val coverImage: String,
val coverImagePreviewUrl: String = "",
val tags: List<String>,
val status: String,
val currentStep: Int,
@@ -153,3 +222,16 @@ data class TraceabilityOssFileResponse(
val fileName: String? = null,
val size: Long? = null,
)
@Serializable
data class TraceabilityFileAssetResponse(
val id: String,
val assetType: String,
val bucketName: String,
val objectName: String,
val fileName: String,
val contentType: String,
val size: Long,
val previewUrl: String,
val createdAt: String,
)
@@ -26,4 +26,11 @@ class AppConfig(config: ApplicationConfig) {
val ossDefaultBucket: String = config.property("ktor.oss.default-bucket").getString()
val ossFallbackBucket: String = config.property("ktor.oss.fallback-bucket").getString()
val ossFallbackObject: String = config.property("ktor.oss.fallback-object").getString()
val traceabilityPublicPreviewBaseUrl: String =
config.propertyOrNull("ktor.traceability.public-preview-base-url")
?.getString()
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8081"
}
@@ -3,12 +3,19 @@ package ink.snowflake.server.utils.dao
import ink.snowflake.server.model.database.TraceabilityBatchStepsTable
import ink.snowflake.server.model.database.TraceabilityBatchesTable
import ink.snowflake.server.model.database.TraceabilityFeedbackTable
import ink.snowflake.server.model.database.TraceabilityFileAssetsTable
import ink.snowflake.server.model.database.TraceabilityNodeLibraryTable
import ink.snowflake.server.model.database.TraceabilityPreviewNodesTable
import ink.snowflake.server.model.database.TraceabilityPreviewPagesTable
import ink.snowflake.server.model.database.TraceabilityTemplateNodesTable
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
import ink.snowflake.server.model.request.TracePreviewNodeRequest
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
import ink.snowflake.server.model.request.templateUuid
@@ -16,19 +23,30 @@ import ink.snowflake.server.model.response.TraceBatchDetailResponse
import ink.snowflake.server.model.response.TraceBatchStepResponse
import ink.snowflake.server.model.response.TraceBatchSummaryResponse
import ink.snowflake.server.model.response.TraceFieldDefinitionResponse
import ink.snowflake.server.model.response.TraceFieldStyleResponse
import ink.snowflake.server.model.response.TraceNodeLibraryResponse
import ink.snowflake.server.model.response.TracePreviewNodeResponse
import ink.snowflake.server.model.response.TracePreviewPageDetailResponse
import ink.snowflake.server.model.response.TracePreviewPageSummaryResponse
import ink.snowflake.server.model.response.TraceTemplateDetailResponse
import ink.snowflake.server.model.response.TraceTemplateNodeResponse
import ink.snowflake.server.model.response.TraceTemplateSummaryResponse
import ink.snowflake.server.model.response.TraceabilityFeedbackResponse
import ink.snowflake.server.model.response.TraceabilityFileAssetResponse
import ink.snowflake.server.model.response.TraceabilityOverviewResponse
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.OSSUtils
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and
@@ -47,21 +65,24 @@ object TraceabilityDao {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val publicPreviewBaseUrl =
System.getenv("TRACEABILITY_PUBLIC_PREVIEW_BASE_URL")
?.trim()
?.trimEnd('/')
?.takeIf { it.isNotBlank() }
?: "http://127.0.0.1:8081"
private var publicPreviewBaseUrl: String = "http://127.0.0.1:8081"
fun init(config: AppConfig) {
publicPreviewBaseUrl = config.traceabilityPublicPreviewBaseUrl
}
fun initSchema() {
transaction {
SchemaUtils.createMissingTablesAndColumns(
TraceabilityTemplatesTable,
TraceabilityTemplateNodesTable,
TraceabilityPreviewPagesTable,
TraceabilityPreviewNodesTable,
TraceabilityNodeLibraryTable,
TraceabilityBatchesTable,
TraceabilityBatchStepsTable,
TraceabilityFeedbackTable,
TraceabilityFileAssetsTable,
)
}
}
@@ -79,8 +100,374 @@ object TraceabilityDao {
)
}
fun listPreviewPages(): List<TracePreviewPageSummaryResponse> = transaction {
TraceabilityPreviewPagesTable.selectAll()
.orderBy(TraceabilityPreviewPagesTable.updatedAt, SortOrder.DESC)
.map {
val code = it[TraceabilityPreviewPagesTable.previewCode]
TracePreviewPageSummaryResponse(
id = it[TraceabilityPreviewPagesTable.id].value.toString(),
name = it[TraceabilityPreviewPagesTable.name],
previewCode = code,
description = it[TraceabilityPreviewPagesTable.description],
productName = it[TraceabilityPreviewPagesTable.productName],
coverImage = it[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
themeColor = it[TraceabilityPreviewPagesTable.themeColor],
tags = decodeStringList(it[TraceabilityPreviewPagesTable.tagsJson]),
publicUrl = previewPublicUrl(code),
updatedAt = formatTimestamp(it[TraceabilityPreviewPagesTable.updatedAt]),
)
}
}
fun getPreviewPage(previewId: UUID): TracePreviewPageDetailResponse? = transaction {
val row = TraceabilityPreviewPagesTable.selectAll()
.where { TraceabilityPreviewPagesTable.id eq previewId }
.singleOrNull() ?: return@transaction null
val code = row[TraceabilityPreviewPagesTable.previewCode]
TracePreviewPageDetailResponse(
id = row[TraceabilityPreviewPagesTable.id].value.toString(),
name = row[TraceabilityPreviewPagesTable.name],
previewCode = code,
description = row[TraceabilityPreviewPagesTable.description],
productName = row[TraceabilityPreviewPagesTable.productName],
coverImage = row[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(row[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
themeColor = row[TraceabilityPreviewPagesTable.themeColor],
tags = decodeStringList(row[TraceabilityPreviewPagesTable.tagsJson]),
publicUrl = previewPublicUrl(code),
nodes = loadPreviewNodes(previewId),
updatedAt = formatTimestamp(row[TraceabilityPreviewPagesTable.updatedAt]),
)
}
fun savePreviewPage(previewId: UUID?, request: SaveTracePreviewPageRequest): TracePreviewPageDetailResponse = transaction {
val now = timestampLiteral(nowInstant())
val currentId = previewId ?: TraceabilityPreviewPagesTable.insertAndGetId {
it[name] = request.name
it[previewCode] = buildPreviewCode()
it[description] = request.description
it[productName] = request.productName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
it[tagsJson] = json.encodeToString(request.tags)
it[createdAt] = now
it[updatedAt] = now
}.value
if (previewId != null) {
TraceabilityPreviewPagesTable.update({ TraceabilityPreviewPagesTable.id eq currentId }) {
it[name] = request.name
it[description] = request.description
it[productName] = request.productName
it[coverImage] = request.coverImage
it[themeColor] = request.themeColor
it[tagsJson] = json.encodeToString(request.tags)
it[updatedAt] = now
}
TraceabilityPreviewNodesTable.deleteWhere { TraceabilityPreviewNodesTable.previewPageId eq currentId }
}
request.nodes.forEachIndexed { index, node ->
TraceabilityPreviewNodesTable.insertAndGetId {
it[previewPageId] = currentId
it[sort] = index
it[category] = node.category
it[name] = node.name
it[description] = node.description
it[consumerVisible] = node.consumerVisible
it[fieldsJson] = json.encodeToString(node.fields)
it[valuesJson] = json.encodeToString(node.values)
it[createdAt] = now
it[updatedAt] = now
}
}
getPreviewPage(currentId)!!
}
fun deletePreviewPage(previewId: UUID): Boolean = transaction {
TraceabilityPreviewNodesTable.deleteWhere { TraceabilityPreviewNodesTable.previewPageId eq previewId }
TraceabilityPreviewPagesTable.deleteWhere { TraceabilityPreviewPagesTable.id eq previewId } > 0
}
fun syncPreviewToTemplate(previewId: UUID): TraceTemplateDetailResponse? = transaction {
val detail = getPreviewPage(previewId) ?: return@transaction null
val request = SaveTraceTemplateRequest(
name = detail.name,
description = detail.description,
productName = detail.productName,
coverImage = detail.coverImage,
themeColor = detail.themeColor,
status = "draft",
nodes = detail.nodes.map { node ->
ink.snowflake.server.model.request.TraceTemplateNodeRequest(
category = node.category,
name = node.name,
description = node.description,
locked = false,
consumerVisible = node.consumerVisible,
fields = node.fields.map { field ->
val value = node.values[field.key] ?: field.defaultValue
TraceFieldDefinitionRequest(
key = field.key,
label = field.label,
type = field.type,
required = field.required,
visible = field.visible,
fixedPreset = field.fixedPreset,
placeholder = field.placeholder,
defaultValue = value,
options = field.options,
fieldStyle = ink.snowflake.server.model.request.TraceFieldStyleRequest(
bold = field.fieldStyle.bold,
color = field.fieldStyle.color,
),
)
},
)
},
)
saveTemplate(null, request)
}
fun recordFileAsset(
assetType: String,
bucketName: String,
objectName: String,
fileName: String,
contentType: String,
size: Long,
) = transaction {
val now = timestampLiteral(nowInstant())
TraceabilityFileAssetsTable.insertAndGetId {
it[this.assetType] = assetType.ifBlank { "general" }
it[this.bucketName] = bucketName
it[this.objectName] = objectName
it[this.fileName] = fileName
it[this.contentType] = contentType
it[this.size] = size
it[createdAt] = now
}
}
fun listFileAssets(assetType: String, limit: Int = 24): List<TraceabilityFileAssetResponse> = transaction {
val items = LinkedHashMap<String, TraceabilityFileAssetResponse>()
TraceabilityFileAssetsTable.selectAll()
.where { TraceabilityFileAssetsTable.assetType eq assetType }
.orderBy(TraceabilityFileAssetsTable.createdAt, SortOrder.DESC)
.limit(limit)
.forEach {
val bucketName = it[TraceabilityFileAssetsTable.bucketName]
val objectName = it[TraceabilityFileAssetsTable.objectName]
items["$bucketName::$objectName"] = TraceabilityFileAssetResponse(
id = it[TraceabilityFileAssetsTable.id].value.toString(),
assetType = it[TraceabilityFileAssetsTable.assetType],
bucketName = bucketName,
objectName = objectName,
fileName = it[TraceabilityFileAssetsTable.fileName],
contentType = it[TraceabilityFileAssetsTable.contentType],
size = it[TraceabilityFileAssetsTable.size],
previewUrl = OSSUtils.getTempUrl(bucketName, objectName),
createdAt = formatTimestamp(it[TraceabilityFileAssetsTable.createdAt]),
)
}
if (assetType == "cover" && items.size < limit) {
val legacySources = buildList {
addAll(
TraceabilityTemplatesTable.selectAll()
.where { TraceabilityTemplatesTable.coverImage neq "" }
.map { it[TraceabilityTemplatesTable.coverImage] to formatTimestamp(it[TraceabilityTemplatesTable.updatedAt]) },
)
addAll(
TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.coverImage neq "" }
.map { it[TraceabilityBatchesTable.coverImage] to formatTimestamp(it[TraceabilityBatchesTable.updatedAt]) },
)
}
legacySources.forEachIndexed { index, (raw, createdAt) ->
if (items.size >= limit) return@forEachIndexed
val previewUrl = resolveStoredImagePreviewUrl(raw) ?: return@forEachIndexed
val (bucketName, objectName) = extractStoredImageReference(raw)
val identity = if (objectName.isNotBlank()) "$bucketName::$objectName" else "legacy::$previewUrl"
if (items.containsKey(identity)) return@forEachIndexed
items[identity] = TraceabilityFileAssetResponse(
id = "legacy-cover-$index",
assetType = assetType,
bucketName = bucketName,
objectName = objectName.ifBlank { previewUrl },
fileName = objectName.substringAfterLast('/').ifBlank { "历史封面图" },
contentType = "image/*",
size = 0,
previewUrl = previewUrl,
createdAt = createdAt,
)
}
}
items.values.take(limit)
}
fun deleteFileAsset(assetId: UUID): Pair<Boolean, String> = transaction {
val asset = TraceabilityFileAssetsTable.selectAll()
.where { TraceabilityFileAssetsTable.id eq assetId }
.singleOrNull()
?: return@transaction false to "历史文件不存在"
val bucketName = asset[TraceabilityFileAssetsTable.bucketName]
val objectName = asset[TraceabilityFileAssetsTable.objectName]
val usedByTemplate = TraceabilityTemplatesTable.selectAll().any {
val (bucket, obj) = extractStoredImageReference(it[TraceabilityTemplatesTable.coverImage])
bucket == bucketName && obj == objectName
}
if (usedByTemplate) {
return@transaction false to "该封面图仍被模板使用,无法删除"
}
val usedByBatch = TraceabilityBatchesTable.selectAll().any {
val (bucket, obj) = extractStoredImageReference(it[TraceabilityBatchesTable.coverImage])
bucket == bucketName && obj == objectName
}
if (usedByBatch) {
return@transaction false to "该封面图仍被批次使用,无法删除"
}
runCatching {
OSSUtils.deleteFile(bucketName, objectName)
}
TraceabilityFileAssetsTable.deleteWhere { TraceabilityFileAssetsTable.id eq assetId }
true to "历史封面图已删除"
}
private fun nowInstant() = Clock.System.now()
fun listNodeLibrary(): List<TraceNodeLibraryResponse> = transaction {
ensureDefaultNodeLibrarySeeded()
TraceabilityNodeLibraryTable.selectAll()
.orderBy(TraceabilityNodeLibraryTable.updatedAt, SortOrder.DESC)
.map {
TraceNodeLibraryResponse(
id = it[TraceabilityNodeLibraryTable.id].value.toString(),
category = it[TraceabilityNodeLibraryTable.category],
name = it[TraceabilityNodeLibraryTable.name],
description = it[TraceabilityNodeLibraryTable.description],
consumerVisible = it[TraceabilityNodeLibraryTable.consumerVisible],
fields = decodeFields(it[TraceabilityNodeLibraryTable.fieldsJson]),
updatedAt = formatTimestamp(it[TraceabilityNodeLibraryTable.updatedAt]),
)
}
}
private fun ensureDefaultNodeLibrarySeeded() {
if (TraceabilityNodeLibraryTable.selectAll().limit(1).any()) {
return
}
val now = timestampLiteral(nowInstant())
defaultNodeLibraryPresets().forEach { request ->
TraceabilityNodeLibraryTable.insertAndGetId {
it[category] = request.category
it[name] = request.name
it[description] = request.description
it[consumerVisible] = request.consumerVisible
it[fieldsJson] = json.encodeToString(request.fields)
it[createdAt] = now
it[updatedAt] = now
}
}
}
private fun defaultNodeLibraryPresets(): List<SaveTraceNodeLibraryRequest> {
return listOf(
SaveTraceNodeLibraryRequest(
category = "business",
name = "生产加工节点",
description = "记录原料、工艺、加工批次等业务过程信息。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "process_name", label = "工艺名称"),
TraceFieldDefinitionRequest(key = "operator", label = "负责人"),
TraceFieldDefinitionRequest(key = "production_date", label = "生产日期", type = "date"),
TraceFieldDefinitionRequest(key = "remark", label = "备注"),
),
),
SaveTraceNodeLibraryRequest(
category = "business",
name = "质检检验节点",
description = "记录质检结果、检验员和检验时间。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "inspector", label = "检验员"),
TraceFieldDefinitionRequest(key = "inspection_date", label = "检验日期", type = "date"),
TraceFieldDefinitionRequest(
key = "inspection_result",
label = "检验结果",
type = "select",
options = listOf("合格", "不合格", "复检中"),
),
TraceFieldDefinitionRequest(key = "inspection_note", label = "检验说明"),
),
),
SaveTraceNodeLibraryRequest(
category = "public",
name = "企业信息节点",
description = "面向消费者展示企业名称、产地和联系方式等信息。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "company_name", label = "企业名称"),
TraceFieldDefinitionRequest(key = "origin", label = "产地"),
TraceFieldDefinitionRequest(key = "contact_phone", label = "联系电话"),
TraceFieldDefinitionRequest(key = "company_intro", label = "企业简介"),
),
),
SaveTraceNodeLibraryRequest(
category = "public",
name = "资质证书节点",
description = "展示认证证书、证书编号和有效期。",
consumerVisible = true,
fields = listOf(
TraceFieldDefinitionRequest(key = "certificate_name", label = "证书名称"),
TraceFieldDefinitionRequest(key = "certificate_no", label = "证书编号"),
TraceFieldDefinitionRequest(key = "valid_until", label = "有效期", type = "date"),
TraceFieldDefinitionRequest(key = "certificate_image", label = "证书图片", type = "image"),
),
),
)
}
fun saveNodeLibrary(nodeId: UUID?, request: SaveTraceNodeLibraryRequest): TraceNodeLibraryResponse = transaction {
val now = timestampLiteral(nowInstant())
val currentId = nodeId ?: TraceabilityNodeLibraryTable.insertAndGetId {
it[category] = request.category
it[name] = request.name
it[description] = request.description
it[consumerVisible] = request.consumerVisible
it[fieldsJson] = json.encodeToString(request.fields)
it[createdAt] = now
it[updatedAt] = now
}.value
if (nodeId != null) {
TraceabilityNodeLibraryTable.update({ TraceabilityNodeLibraryTable.id eq currentId }) {
it[category] = request.category
it[name] = request.name
it[description] = request.description
it[consumerVisible] = request.consumerVisible
it[fieldsJson] = json.encodeToString(request.fields)
it[updatedAt] = now
}
}
listNodeLibrary().first { it.id == currentId.toString() }
}
fun deleteNodeLibrary(nodeId: UUID): Boolean = transaction {
TraceabilityNodeLibraryTable.deleteWhere { TraceabilityNodeLibraryTable.id eq nodeId } > 0
}
fun listTemplates(): List<TraceTemplateSummaryResponse> = transaction {
val batchCountByTemplate = TraceabilityBatchesTable.selectAll()
.groupBy { it[TraceabilityBatchesTable.templateId].value }
@@ -99,6 +486,7 @@ object TraceabilityDao {
productName = it[TraceabilityTemplatesTable.productName],
industryName = it[TraceabilityTemplatesTable.industryName],
coverImage = it[TraceabilityTemplatesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityTemplatesTable.coverImage]).orEmpty(),
themeColor = it[TraceabilityTemplatesTable.themeColor],
status = it[TraceabilityTemplatesTable.status],
nodeCount = nodeCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
@@ -120,6 +508,7 @@ object TraceabilityDao {
productName = templateRow[TraceabilityTemplatesTable.productName],
industryName = templateRow[TraceabilityTemplatesTable.industryName],
coverImage = templateRow[TraceabilityTemplatesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(templateRow[TraceabilityTemplatesTable.coverImage]).orEmpty(),
themeColor = templateRow[TraceabilityTemplatesTable.themeColor],
status = templateRow[TraceabilityTemplatesTable.status],
nodes = loadTemplateNodes(templateId),
@@ -154,11 +543,17 @@ object TraceabilityDao {
}
val existingNodeIds = TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.templateId eq currentId }
.map { it[TraceabilityTemplateNodesTable.id].value }
.associate {
it[TraceabilityTemplateNodesTable.id].value to it[TraceabilityTemplateNodesTable.fieldsJson]
}
if (existingNodeIds.isNotEmpty()) {
existingNodeIds.forEach { nodeId ->
TraceabilityBatchStepsTable.deleteWhere {
existingNodeIds.forEach { (nodeId, fieldsJson) ->
TraceabilityBatchStepsTable.update({
TraceabilityBatchStepsTable.templateNodeId eq nodeId
}) {
it[TraceabilityBatchStepsTable.fieldsJson] = fieldsJson
it[templateNodeId] = null
it[updatedAt] = now
}
}
}
@@ -221,6 +616,7 @@ object TraceabilityDao {
productName = it[TraceabilityBatchesTable.productName],
summary = it[TraceabilityBatchesTable.summary],
coverImage = it[TraceabilityBatchesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(it[TraceabilityBatchesTable.coverImage]).orEmpty(),
tags = decodeStringList(it[TraceabilityBatchesTable.tagsJson]),
status = it[TraceabilityBatchesTable.status],
currentStep = it[TraceabilityBatchesTable.currentStep],
@@ -249,6 +645,7 @@ object TraceabilityDao {
productName = batchRow[TraceabilityBatchesTable.productName],
summary = batchRow[TraceabilityBatchesTable.summary],
coverImage = batchRow[TraceabilityBatchesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(batchRow[TraceabilityBatchesTable.coverImage]).orEmpty(),
tags = decodeStringList(batchRow[TraceabilityBatchesTable.tagsJson]),
status = batchRow[TraceabilityBatchesTable.status],
currentStep = batchRow[TraceabilityBatchesTable.currentStep],
@@ -260,8 +657,8 @@ object TraceabilityDao {
)
}
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse = transaction {
val template = getTemplate(request.templateUuid()) ?: error("template not found")
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse? = transaction {
val template = getTemplate(request.templateUuid()) ?: return@transaction null
val now = timestampLiteral(nowInstant())
val batchId = TraceabilityBatchesTable.insertAndGetId {
it[this.templateId] = request.templateUuid()
@@ -269,7 +666,7 @@ object TraceabilityDao {
it[batchCode] = request.batchCode
it[productName] = request.productName
it[summary] = request.summary
it[coverImage] = request.coverImage
it[coverImage] = request.coverImage.ifBlank { template.coverImage }
it[tagsJson] = json.encodeToString(request.tags)
it[status] = "draft"
it[currentStep] = 0
@@ -287,6 +684,7 @@ object TraceabilityDao {
it[description] = node.description
it[locked] = node.locked
it[consumerVisible] = node.consumerVisible
it[fieldsJson] = json.encodeToString(node.fields)
it[status] = "pending"
it[operatorName] = ""
it[valuesJson] = buildDefaultValues(node.fields)
@@ -295,7 +693,7 @@ object TraceabilityDao {
}
}
getBatch(batchId)!!
getBatch(batchId)
}
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
@@ -357,11 +755,19 @@ object TraceabilityDao {
getBatch(batchId)
}
fun getPublicDetailByCode(batchCode: String, increaseScan: Boolean = false): TraceabilityPublicDetailResponse? = transaction {
fun getPublicDetailByCode(
batchCode: String,
increaseScan: Boolean = false,
onlyPublished: Boolean = true,
): TraceabilityPublicDetailResponse? = transaction {
val batchRow = TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.batchCode eq batchCode }
.singleOrNull() ?: return@transaction null
if (onlyPublished && batchRow[TraceabilityBatchesTable.status] != "published") {
return@transaction null
}
if (increaseScan) {
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchRow[TraceabilityBatchesTable.id].value }) {
it[scanCount] = batchRow[TraceabilityBatchesTable.scanCount] + 1
@@ -377,6 +783,52 @@ object TraceabilityDao {
)
}
fun getPreviewPublicDetailByCode(previewCode: String): TraceabilityPublicDetailResponse? = transaction {
val pageRow = TraceabilityPreviewPagesTable.selectAll()
.where { TraceabilityPreviewPagesTable.previewCode eq previewCode }
.singleOrNull() ?: return@transaction null
val pageId = pageRow[TraceabilityPreviewPagesTable.id].value
val nodes = loadPreviewNodes(pageId)
val batchLike = TraceBatchDetailResponse(
id = pageId.toString(),
templateId = "",
templateName = "预演页",
batchName = pageRow[TraceabilityPreviewPagesTable.name],
batchCode = pageRow[TraceabilityPreviewPagesTable.previewCode],
productName = pageRow[TraceabilityPreviewPagesTable.productName],
summary = pageRow[TraceabilityPreviewPagesTable.description],
coverImage = pageRow[TraceabilityPreviewPagesTable.coverImage],
coverImagePreviewUrl = resolveStoredImagePreviewUrl(pageRow[TraceabilityPreviewPagesTable.coverImage]).orEmpty(),
tags = decodeStringList(pageRow[TraceabilityPreviewPagesTable.tagsJson]),
status = "preview",
currentStep = 0,
scanCount = 0,
publicUrl = previewPublicUrl(pageRow[TraceabilityPreviewPagesTable.previewCode]),
steps = nodes.map {
TraceBatchStepResponse(
id = it.id,
sort = it.sort,
category = it.category,
name = it.name,
description = it.description,
consumerVisible = it.consumerVisible,
status = "preview",
operatorName = "",
values = it.values,
valuePreviewUrls = it.valuePreviewUrls,
fields = it.fields,
)
},
updatedAt = formatTimestamp(pageRow[TraceabilityPreviewPagesTable.updatedAt]),
publishedAt = formatTimestamp(pageRow[TraceabilityPreviewPagesTable.updatedAt]),
)
TraceabilityPublicDetailResponse(
batch = batchLike,
publicSections = batchLike.steps.filter { it.category == "public" && it.consumerVisible },
businessSections = batchLike.steps.filter { it.category != "public" && it.consumerVisible },
)
}
fun listFeedback(): List<TraceabilityFeedbackResponse> = transaction {
val batchMap = TraceabilityBatchesTable.selectAll()
.associateBy { it[TraceabilityBatchesTable.id].value }
@@ -400,14 +852,14 @@ object TraceabilityDao {
}
}
fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): TraceabilityFeedbackResponse = transaction {
fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): TraceabilityFeedbackResponse? = transaction {
val batchId = when {
!request.batchId.isNullOrBlank() -> UUID.fromString(request.batchId)
!request.batchId.isNullOrBlank() -> runCatching { UUID.fromString(request.batchId) }.getOrNull()
!request.batchCode.isNullOrBlank() -> TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.batchCode eq request.batchCode }
.single()[TraceabilityBatchesTable.id].value
else -> error("batch not found")
}
.singleOrNull()?.get(TraceabilityBatchesTable.id)?.value
else -> null
} ?: return@transaction null
val now = timestampLiteral(nowInstant())
val feedbackId = TraceabilityFeedbackTable.insertAndGetId {
@@ -420,7 +872,7 @@ object TraceabilityDao {
it[createdAt] = now
}.value
listFeedback().first { it.id == feedbackId.toString() }
listFeedback().firstOrNull { it.id == feedbackId.toString() }
}
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
@@ -441,12 +893,34 @@ object TraceabilityDao {
}
}
private fun loadPreviewNodes(previewId: UUID): List<TracePreviewNodeResponse> {
return TraceabilityPreviewNodesTable.selectAll()
.where { TraceabilityPreviewNodesTable.previewPageId eq previewId }
.orderBy(TraceabilityPreviewNodesTable.sort, SortOrder.ASC)
.map { row ->
val fields = decodeFields(row[TraceabilityPreviewNodesTable.fieldsJson])
val values = decodeValues(row[TraceabilityPreviewNodesTable.valuesJson])
TracePreviewNodeResponse(
id = row[TraceabilityPreviewNodesTable.id].value.toString(),
sort = row[TraceabilityPreviewNodesTable.sort],
category = row[TraceabilityPreviewNodesTable.category],
name = row[TraceabilityPreviewNodesTable.name],
description = row[TraceabilityPreviewNodesTable.description],
consumerVisible = row[TraceabilityPreviewNodesTable.consumerVisible],
values = values,
valuePreviewUrls = buildValuePreviewUrls(fields, values),
fields = fields,
)
}
}
private fun loadBatchSteps(batchId: UUID): List<TraceBatchStepResponse> {
return TraceabilityBatchStepsTable.selectAll()
.where { TraceabilityBatchStepsTable.batchId eq batchId }
.orderBy(TraceabilityBatchStepsTable.sort, SortOrder.ASC)
.map { row ->
val fields = row[TraceabilityBatchStepsTable.templateNodeId]?.value?.let { nodeId ->
val snapshotFields = decodeFields(row[TraceabilityBatchStepsTable.fieldsJson])
val fields = snapshotFields.takeIf { it.isNotEmpty() } ?: row[TraceabilityBatchStepsTable.templateNodeId]?.value?.let { nodeId ->
TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.id eq nodeId }
.singleOrNull()
@@ -465,6 +939,10 @@ object TraceabilityDao {
status = row[TraceabilityBatchStepsTable.status],
operatorName = row[TraceabilityBatchStepsTable.operatorName],
values = decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
valuePreviewUrls = buildValuePreviewUrls(
fields,
decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
),
completedAt = formatTimestamp(row[TraceabilityBatchStepsTable.completedAt]),
fields = fields,
)
@@ -479,9 +957,15 @@ object TraceabilityDao {
type = it.type,
required = it.required,
visible = it.visible,
fixedPreset = it.fixedPreset,
placeholder = it.placeholder,
defaultValue = it.defaultValue,
defaultPreviewUrl = if (it.type == "image") resolveImagePreviewUrl(it.defaultValue) else null,
options = it.options,
fieldStyle = TraceFieldStyleResponse(
bold = it.fieldStyle.bold,
color = it.fieldStyle.color,
),
)
}
}
@@ -498,6 +982,81 @@ object TraceabilityDao {
emptyList()
}
private fun buildValuePreviewUrls(
fields: List<TraceFieldDefinitionResponse>,
values: JsonObject,
): Map<String, String> {
return fields.filter { it.type == "image" }
.mapNotNull { field ->
resolveImagePreviewUrl(values[field.key])?.let { field.key to it }
}
.toMap()
}
private fun resolveStoredImagePreviewUrl(raw: String?): String? {
val text = raw?.trim().orEmpty()
if (text.isBlank()) {
return null
}
return runCatching {
if (text.startsWith("{")) {
resolveImagePreviewUrl(json.parseToJsonElement(text))
} else {
resolveImagePreviewUrl(JsonPrimitive(text))
}
}.getOrNull()
}
private fun extractStoredImageReference(raw: String?): Pair<String, String> {
val text = raw?.trim().orEmpty()
if (text.isBlank()) {
return "" to ""
}
return runCatching {
if (text.startsWith("{")) {
val element = json.parseToJsonElement(text)
if (element is JsonObject) {
val bucketName = element["bucketName"]?.jsonPrimitive?.content?.trim().orEmpty()
val objectName = element["objectName"]?.jsonPrimitive?.content?.trim().orEmpty()
bucketName to objectName
} else {
"" to text
}
} else if (text.startsWith("http://") || text.startsWith("https://")) {
"" to text
} else {
OSSUtils.defaultBucket() to text
}
}.getOrDefault("" to text)
}
private fun resolveImagePreviewUrl(value: JsonElement?): String? = runCatching {
when (value) {
null, JsonNull -> null
is JsonPrimitive -> {
val raw = value.content.trim()
when {
raw.isBlank() -> null
raw.startsWith("http://") || raw.startsWith("https://") -> raw
else -> OSSUtils.getTempUrl(OSSUtils.defaultBucket(), raw)
}
}
is JsonObject -> {
val bucketName = value["bucketName"]?.jsonPrimitive?.content?.trim().orEmpty()
.ifBlank { OSSUtils.defaultBucket() }
val objectName = value["objectName"]?.jsonPrimitive?.content?.trim().orEmpty()
if (objectName.isBlank()) {
null
} else {
OSSUtils.getTempUrl(bucketName, objectName)
}
}
else -> null
}
}.getOrNull()
private fun buildDefaultValues(fields: List<TraceFieldDefinitionResponse>): String {
val values = buildJsonObject {
fields.forEach { field ->
@@ -511,6 +1070,15 @@ object TraceabilityDao {
value?.toString()?.replace('T', ' ')?.replace("Z", "") ?: ""
private fun publicUrl(code: String): String {
return "$publicPreviewBaseUrl/p/$code"
return "$publicPreviewBaseUrl/f10/$code"
}
private fun previewPublicUrl(code: String): String {
return "$publicPreviewBaseUrl/preview/$code"
}
private fun buildPreviewCode(): String {
return "PV-${Clock.System.now().epochSeconds.toString().takeLast(8)}-${UUID.randomUUID().toString().take(6)}"
.uppercase()
}
}
+4
View File
@@ -40,3 +40,7 @@ ktor:
fallback-bucket: "system"
fallback-object: "favicon.ico"
traceability:
# public-preview-base-url: "http://127.0.0.1:8081" # 开发测试用
public-preview-base-url: "https://ats.f10.bbitcn.com" # 生产环境用