溯源系统初版
This commit is contained in:
@@ -6,9 +6,11 @@ import ink.snowflake.server.controller.chat
|
||||
import ink.snowflake.server.utils.plugins.configureSockets
|
||||
import ink.snowflake.server.controller.ImageAnalytics
|
||||
import ink.snowflake.server.controller.RemoteDebug
|
||||
import ink.snowflake.server.controller.Traceability
|
||||
import ink.snowflake.server.controller.VideoAnalytics
|
||||
import ink.snowflake.server.controller.VideoAnalyticsJetson
|
||||
import ink.snowflake.server.utils.AppConfig
|
||||
import ink.snowflake.server.utils.OSSUtils
|
||||
import ink.snowflake.server.utils.plugins.configureCORS
|
||||
import ink.snowflake.server.utils.plugins.configureDatabases
|
||||
import ink.snowflake.server.utils.plugins.configureSecurity
|
||||
@@ -54,6 +56,8 @@ fun Application.module() {
|
||||
configureCORS()
|
||||
// 设置数据库
|
||||
configureDatabases(appConfig)
|
||||
// OSS / MinIO
|
||||
OSSUtils.init(appConfig)
|
||||
// 状态拦截
|
||||
configureStatusPages()
|
||||
// 设置-WebSocket
|
||||
@@ -71,4 +75,5 @@ fun Application.module() {
|
||||
VideoAnalyticsJetson()
|
||||
// 业务-图片分析
|
||||
ImageAnalytics()
|
||||
Traceability()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
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.SubmitTraceabilityFeedbackRequest
|
||||
import ink.snowflake.server.model.request.TraceabilityOssDeleteRequest
|
||||
import ink.snowflake.server.model.request.TraceabilityOssMoveRequest
|
||||
import ink.snowflake.server.model.request.TraceabilityOssPresignRequest
|
||||
import ink.snowflake.server.model.request.TraceabilityOssTempUrlRequest
|
||||
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
|
||||
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
|
||||
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.OSSUtils
|
||||
import ink.snowflake.server.utils.dao.TraceabilityDao
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.content.*
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.request.receiveMultipart
|
||||
import io.ktor.server.request.receive
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.delete
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.put
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
fun Application.Traceability() {
|
||||
TraceabilityDao.initSchema()
|
||||
|
||||
routing {
|
||||
route("/traceability") {
|
||||
get("/overview") {
|
||||
call.respond(BaseResponse(data = TraceabilityDao.getOverview()))
|
||||
}
|
||||
route("/templates") {
|
||||
get {
|
||||
call.respond(BaseResponse(data = TraceabilityDao.listTemplates()))
|
||||
}
|
||||
post {
|
||||
val request = call.receive<SaveTraceTemplateRequest>()
|
||||
call.respond(BaseResponse(data = TraceabilityDao.saveTemplate(null, request)))
|
||||
}
|
||||
get("/{id}") {
|
||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
||||
val data = id?.let(TraceabilityDao::getTemplate)
|
||||
if (data == null) {
|
||||
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "模板不存在", data = null))
|
||||
return@get
|
||||
}
|
||||
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)))
|
||||
}
|
||||
delete("/{id}") {
|
||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
||||
if (id == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "模板ID无效", data = null))
|
||||
return@delete
|
||||
}
|
||||
val deleted = TraceabilityDao.deleteTemplate(id)
|
||||
call.respond(BaseResponse(status = deleted, message = if (deleted) "模板已删除" else "模板不存在", data = deleted))
|
||||
}
|
||||
}
|
||||
|
||||
route("/batches") {
|
||||
get {
|
||||
call.respond(BaseResponse(data = TraceabilityDao.listBatches()))
|
||||
}
|
||||
post {
|
||||
val request = call.receive<CreateTraceBatchRequest>()
|
||||
call.respond(BaseResponse(data = TraceabilityDao.createBatch(request)))
|
||||
}
|
||||
delete("/{id}") {
|
||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
||||
if (id == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
||||
return@delete
|
||||
}
|
||||
val deleted = TraceabilityDao.deleteBatch(id)
|
||||
call.respond(BaseResponse(status = deleted, message = if (deleted) "批次已删除" else "批次不存在", data = deleted))
|
||||
}
|
||||
get("/{id}") {
|
||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
||||
val data = id?.let(TraceabilityDao::getBatch)
|
||||
if (data == null) {
|
||||
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
|
||||
return@get
|
||||
}
|
||||
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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
post("/{id}/publish") {
|
||||
val id = call.parameters["id"]?.let(UUID::fromString)
|
||||
if (id == null) {
|
||||
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
|
||||
return@post
|
||||
}
|
||||
val data = TraceabilityDao.publishBatch(id)
|
||||
if (data == null) {
|
||||
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
|
||||
return@post
|
||||
}
|
||||
call.respond(BaseResponse(message = "批次已发布", data = data))
|
||||
}
|
||||
}
|
||||
|
||||
route("/feedback") {
|
||||
get {
|
||||
call.respond(BaseResponse(data = TraceabilityDao.listFeedback()))
|
||||
}
|
||||
post {
|
||||
val request = call.receive<SubmitTraceabilityFeedbackRequest>()
|
||||
call.respond(BaseResponse(message = "反馈已提交", data = TraceabilityDao.submitFeedback(request)))
|
||||
}
|
||||
}
|
||||
route("/public") {
|
||||
get("/by-code/{code}") {
|
||||
val code = call.parameters["code"] ?: ""
|
||||
val increaseScan = call.request.queryParameters["increaseScan"] == "true"
|
||||
val data = TraceabilityDao.getPublicDetailByCode(code, increaseScan)
|
||||
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)))
|
||||
}
|
||||
get("/page/{code}") {
|
||||
val code = call.parameters["code"] ?: ""
|
||||
val data = TraceabilityDao.getPublicDetailByCode(code, true)
|
||||
if (data == null) {
|
||||
call.respondText("Traceability data not found", status = HttpStatusCode.NotFound)
|
||||
return@get
|
||||
}
|
||||
call.respondText(renderTraceabilityPage(data), ContentType.Text.Html)
|
||||
}
|
||||
}
|
||||
|
||||
route("/files") {
|
||||
post("/upload-image") {
|
||||
val multipart = call.receiveMultipart()
|
||||
var bucketName = OSSUtils.defaultBucket()
|
||||
var objectDir = "traceability/images"
|
||||
var objectName = ""
|
||||
var response: TraceabilityOssFileResponse? = null
|
||||
|
||||
multipart.forEachPart { part ->
|
||||
when (part) {
|
||||
is PartData.FormItem -> {
|
||||
when (part.name) {
|
||||
"bucketName" -> bucketName = part.value.ifBlank { OSSUtils.defaultBucket() }
|
||||
"objectDir" -> objectDir = part.value.ifBlank { "traceability/images" }
|
||||
"objectName" -> objectName = part.value
|
||||
}
|
||||
}
|
||||
|
||||
is PartData.FileItem -> {
|
||||
val fileName = part.originalFileName ?: "image"
|
||||
val ext = fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
|
||||
val finalObjectName = objectName.ifBlank {
|
||||
listOfNotNull(
|
||||
objectDir.takeIf { it.isNotBlank() },
|
||||
"${UUID.randomUUID()}${if (ext.isNotBlank()) ".$ext" else ""}",
|
||||
).joinToString("/")
|
||||
}
|
||||
val contentType = part.contentType?.toString() ?: "application/octet-stream"
|
||||
val bytes = part.streamProvider().use { input -> input.readBytes() }
|
||||
OSSUtils.pushFile(bucketName, finalObjectName, bytes, contentType)
|
||||
response = TraceabilityOssFileResponse(
|
||||
bucketName = bucketName,
|
||||
objectName = finalObjectName,
|
||||
tempUrl = OSSUtils.getTempUrl(bucketName, finalObjectName),
|
||||
contentType = contentType,
|
||||
fileName = fileName,
|
||||
size = bytes.size.toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
part.dispose()
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
call.respond(
|
||||
HttpStatusCode.BadRequest,
|
||||
BaseResponse(status = false, message = "请选择要上传的图片", data = null),
|
||||
)
|
||||
return@post
|
||||
}
|
||||
call.respond(BaseResponse(message = "图片上传成功", data = response))
|
||||
}
|
||||
|
||||
post("/presigned-put") {
|
||||
val request = call.receive<TraceabilityOssPresignRequest>()
|
||||
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
|
||||
val uploadUrl = OSSUtils.getUploadToken(
|
||||
bucketName = bucketName,
|
||||
objectName = request.objectName,
|
||||
expiryMinutes = request.expiresMinutes,
|
||||
)
|
||||
call.respond(
|
||||
BaseResponse(
|
||||
data = TraceabilityOssFileResponse(
|
||||
bucketName = bucketName,
|
||||
objectName = request.objectName,
|
||||
uploadUrl = uploadUrl,
|
||||
tempUrl = OSSUtils.getTempUrl(bucketName, request.objectName),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
post("/temp-url") {
|
||||
val request = call.receive<TraceabilityOssTempUrlRequest>()
|
||||
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
|
||||
val resolvedObjectName = when {
|
||||
!request.objectName.isNullOrBlank() -> request.objectName
|
||||
!request.objectDir.isNullOrBlank() -> request.objectDir
|
||||
else -> null
|
||||
}
|
||||
val tempUrl = if (!request.objectDir.isNullOrBlank() && !request.objectName.isNullOrBlank()) {
|
||||
OSSUtils.getTempUrlDict(
|
||||
bucketName = bucketName,
|
||||
objectDir = request.objectDir,
|
||||
objectName = request.objectName,
|
||||
seconds = request.expiresSeconds,
|
||||
)
|
||||
} else {
|
||||
OSSUtils.getTempUrl(
|
||||
bucketName = bucketName,
|
||||
objectName = resolvedObjectName,
|
||||
seconds = request.expiresSeconds,
|
||||
)
|
||||
}
|
||||
call.respond(
|
||||
BaseResponse(
|
||||
data = TraceabilityOssFileResponse(
|
||||
bucketName = bucketName,
|
||||
objectName = resolvedObjectName ?: "",
|
||||
tempUrl = tempUrl,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
post("/move") {
|
||||
val request = call.receive<TraceabilityOssMoveRequest>()
|
||||
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
|
||||
OSSUtils.moveFile(bucketName, request.sourceObjectName, request.targetObjectName)
|
||||
call.respond(
|
||||
BaseResponse(
|
||||
message = "文件已移动",
|
||||
data = TraceabilityOssFileResponse(
|
||||
bucketName = bucketName,
|
||||
objectName = request.targetObjectName,
|
||||
tempUrl = OSSUtils.getTempUrl(bucketName, request.targetObjectName),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
post("/delete") {
|
||||
val request = call.receive<TraceabilityOssDeleteRequest>()
|
||||
val bucketName = request.bucketName?.ifBlank { OSSUtils.defaultBucket() } ?: OSSUtils.defaultBucket()
|
||||
OSSUtils.deleteFile(bucketName, request.objectName)
|
||||
call.respond(BaseResponse(message = "文件已删除", data = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderTraceabilityPage(detail: TraceabilityPublicDetailResponse): String {
|
||||
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() }
|
||||
?: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1400&q=80"
|
||||
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>${escapeHtml(batch.batchName)} - 溯源信息</title>
|
||||
<style>
|
||||
:root { --bg:#f5f7fb; --panel:rgba(255,255,255,0.92); --line:#e5eaf3; --text:#13213c; --soft:#63708a; --brand:#2458d3; --brand-soft:#edf3ff; --success:#0f8c62; --shadow:0 18px 60px rgba(16,31,67,.08);}
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:"PingFang SC","Microsoft YaHei",sans-serif; color:var(--text); background:radial-gradient(circle at top left, rgba(36,88,211,.10), transparent 35%),linear-gradient(180deg, #fbfcfe 0%, var(--bg) 100%); }
|
||||
a { color:var(--brand); text-decoration:none; }
|
||||
.page { max-width:1240px; margin:0 auto; padding:28px 18px 56px; }
|
||||
.hero { position:relative; overflow:hidden; border-radius:32px; background:linear-gradient(135deg, rgba(255,255,255,.96), rgba(244,248,255,.92)); border:1px solid rgba(255,255,255,.7); box-shadow:var(--shadow); display:grid; grid-template-columns:minmax(0,1.4fr) minmax(320px,.9fr); gap:20px; padding:28px; }
|
||||
.hero::after { content:""; position:absolute; right:-80px; top:-80px; width:220px; height:220px; background:radial-gradient(circle, rgba(36,88,211,.15), transparent 70%); }
|
||||
.eyebrow { display:inline-flex; gap:10px; align-items:center; padding:8px 14px; background:var(--brand-soft); border-radius:999px; color:var(--brand); font-size:13px; font-weight:600; }
|
||||
h1 { margin:18px 0 10px; font-size:34px; line-height:1.2; }
|
||||
.hero p { margin:0; color:var(--soft); line-height:1.75; }
|
||||
.hero-cover { min-height:260px; border-radius:24px; background:linear-gradient(180deg, rgba(19,33,60,.08), rgba(19,33,60,.26)), url('${escapeHtml(cover)}') center/cover no-repeat; display:flex; align-items:end; padding:20px; color:#fff; }
|
||||
.stats { margin-top:18px; display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:12px; }
|
||||
.stat { background:rgba(255,255,255,.86); border:1px solid var(--line); border-radius:18px; padding:16px; }
|
||||
.stat span { display:block; font-size:12px; color:var(--soft); }
|
||||
.stat strong { display:block; margin-top:8px; font-size:20px; }
|
||||
.section { margin-top:22px; background:var(--panel); border:1px solid rgba(255,255,255,.72); border-radius:28px; box-shadow:var(--shadow); padding:24px; backdrop-filter:blur(12px); }
|
||||
.section-head { display:flex; align-items:end; justify-content:space-between; gap:16px; margin-bottom:16px; flex-wrap:wrap; }
|
||||
.section-head h2 { margin:0; font-size:24px; }
|
||||
.section-head p { margin:8px 0 0; color:var(--soft); }
|
||||
.grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:16px; }
|
||||
.section-card,.feedback { background:#fff; border:1px solid var(--line); border-radius:22px; padding:20px; }
|
||||
.section-card h3 { margin:0; font-size:18px; }
|
||||
.muted { margin:8px 0 0; color:var(--soft); line-height:1.7; }
|
||||
.kv-grid { margin-top:16px; display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:12px; }
|
||||
.kv { border-radius:16px; background:#f8faff; border:1px solid #e9eef6; padding:14px; min-height:82px; }
|
||||
.kv span { display:block; font-size:12px; color:var(--soft); }
|
||||
.kv strong { display:block; margin-top:8px; line-height:1.65; font-size:14px; word-break:break-word; }
|
||||
.timeline { display:grid; gap:18px; }
|
||||
.timeline-item { display:grid; grid-template-columns:44px minmax(0,1fr); gap:16px; align-items:start; }
|
||||
.timeline-rail { display:flex; flex-direction:column; align-items:center; height:100%; }
|
||||
.dot { width:18px; height:18px; border-radius:50%; background:var(--brand); box-shadow:0 0 0 6px rgba(36,88,211,.12); margin-top:12px; }
|
||||
.line { width:2px; flex:1; min-height:80px; margin-top:8px; background:linear-gradient(180deg, rgba(36,88,211,.35), rgba(36,88,211,.05)); }
|
||||
.timeline-card { border-radius:24px; border:1px solid var(--line); background:linear-gradient(180deg, #ffffff 0%, #fbfcff 100%); padding:20px; }
|
||||
.timeline-meta { display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; }
|
||||
.tag { padding:6px 10px; background:#eef6f1; border-radius:999px; color:var(--success); font-size:12px; font-weight:600; }
|
||||
.feedback-grid { display:grid; grid-template-columns:1.1fr .9fr; gap:16px; }
|
||||
.feedback form { display:grid; gap:12px; }
|
||||
input,textarea,select,button { font:inherit; }
|
||||
input,textarea,select { width:100%; border:1px solid #dbe3ef; border-radius:14px; padding:12px 14px; background:#fff; color:var(--text); }
|
||||
textarea { min-height:120px; resize:vertical; }
|
||||
button { border:none; border-radius:14px; padding:12px 18px; background:linear-gradient(135deg, #2b63e3, #1f4fd6); color:#fff; cursor:pointer; font-weight:600; }
|
||||
.link-box { border:1px dashed #d7e1f0; border-radius:18px; padding:16px; background:#fafcff; color:var(--soft); line-height:1.8; }
|
||||
@media (max-width:960px) { .hero,.feedback-grid,.grid,.stats,.kv-grid { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<section class="hero">
|
||||
<div>
|
||||
<span class="eyebrow">可信溯源链 · 实时公开</span>
|
||||
<h1>${escapeHtml(batch.batchName)}</h1>
|
||||
<p>${escapeHtml(batch.summary.ifBlank { "该批次已完成关键环节上链归档,消费者可查看从生产、质检到包装发布的完整履历信息。" })}</p>
|
||||
<div class="stats">
|
||||
<div class="stat"><span>批次编码</span><strong>${escapeHtml(batch.batchCode)}</strong></div>
|
||||
<div class="stat"><span>当前状态</span><strong>${escapeHtml(batch.status)}</strong></div>
|
||||
<div class="stat"><span>累计扫码</span><strong>${batch.scanCount}</strong></div>
|
||||
<div class="stat"><span>流程节点</span><strong>${batch.steps.size}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-cover"><div><strong>${escapeHtml(batch.productName.ifBlank { batch.templateName })}</strong><div style="margin-top:8px;opacity:.88;">更新时间:${escapeHtml(batch.updatedAt)}</div></div></div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><h2>公开资料</h2><p>企业、地域、资质等面向消费者展示的信息在这里集中呈现。</p></div><div class="link-box">公开访问链接:<a href="${escapeHtml(batch.publicUrl)}">${escapeHtml(batch.publicUrl)}</a></div></div>
|
||||
<div class="grid">$publicCards</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<div class="section-head"><div><h2>流程时间轴</h2><p>按业务环节顺序查看本批次的生产过程与关键留痕。</p></div></div>
|
||||
<div class="timeline">$timelineCards</div>
|
||||
</section>
|
||||
<section class="section">
|
||||
<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>
|
||||
<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>
|
||||
<div class="feedback">
|
||||
<h3 style="margin-top:0;">查看说明</h3>
|
||||
<p class="muted">1. 当前页面由 Ktor 动态生成,适合扫码后直接打开。</p>
|
||||
<p class="muted">2. 所有内容都来自模板字段与批次数据,无需单独开发每个行业页面。</p>
|
||||
<p class="muted">3. 你可以在管理端调整模板结构,在业务端完善每个批次节点。</p>
|
||||
<div id="feedback-result" class="muted" style="margin-top:16px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
const form = document.getElementById('feedback-form');
|
||||
const result = document.getElementById('feedback-result');
|
||||
form?.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
const payload = { batchCode: '${escapeHtml(batch.batchCode)}', source: 'public', type: formData.get('type'), contact: formData.get('contact'), content: formData.get('content'), rating: Number(formData.get('rating') || 5) };
|
||||
try {
|
||||
const resp = await fetch('/traceability/public/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
|
||||
const data = await resp.json();
|
||||
if (data?.status) { form.reset(); result.textContent = '反馈已提交,感谢你的建议。'; } else { result.textContent = data?.message || '提交失败,请稍后重试。'; }
|
||||
} catch (error) { result.textContent = '网络异常,请稍后重试。'; }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
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>"""
|
||||
|
||||
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>"""
|
||||
}
|
||||
|
||||
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 formatJsonValue(value: JsonElement): String = when (value) {
|
||||
is JsonArray -> value.joinToString("、") { formatJsonValue(it) }
|
||||
is JsonObject -> value.entries.joinToString(";") { "${it.key}: ${formatJsonValue(it.value)}" }
|
||||
else -> value.toString().trim('"')
|
||||
}
|
||||
|
||||
private fun escapeHtml(value: String): String =
|
||||
value.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """)
|
||||
.replace("'", "'")
|
||||
@@ -0,0 +1,72 @@
|
||||
package ink.snowflake.server.model.database
|
||||
|
||||
import org.jetbrains.exposed.v1.core.dao.id.UUIDTable
|
||||
import org.jetbrains.exposed.v1.datetime.timestamp
|
||||
|
||||
object TraceabilityTemplatesTable : UUIDTable("traceability_templates") {
|
||||
val name = varchar("name", 120)
|
||||
val description = text("description").default("")
|
||||
val productName = varchar("product_name", 120).default("")
|
||||
val industryName = varchar("industry_name", 120).default("")
|
||||
val coverImage = text("cover_image").default("")
|
||||
val themeColor = varchar("theme_color", 20).default("#1f4fd6")
|
||||
val status = varchar("status", 32).default("draft")
|
||||
val createdAt = timestamp("created_at").nullable()
|
||||
val updatedAt = timestamp("updated_at").nullable()
|
||||
}
|
||||
|
||||
object TraceabilityTemplateNodesTable : UUIDTable("traceability_template_nodes") {
|
||||
val templateId = reference("template_id", TraceabilityTemplatesTable)
|
||||
val sort = integer("sort").default(0)
|
||||
val category = varchar("category", 32).default("business")
|
||||
val name = varchar("name", 120)
|
||||
val description = text("description").default("")
|
||||
val locked = bool("locked").default(false)
|
||||
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)
|
||||
val batchCode = varchar("batch_code", 120).uniqueIndex()
|
||||
val productName = varchar("product_name", 120).default("")
|
||||
val summary = text("summary").default("")
|
||||
val coverImage = text("cover_image").default("")
|
||||
val tagsJson = text("tags_json").default("[]")
|
||||
val status = varchar("status", 32).default("draft")
|
||||
val currentStep = integer("current_step").default(0)
|
||||
val scanCount = integer("scan_count").default(0)
|
||||
val publishedAt = timestamp("published_at").nullable()
|
||||
val createdAt = timestamp("created_at").nullable()
|
||||
val updatedAt = timestamp("updated_at").nullable()
|
||||
}
|
||||
|
||||
object TraceabilityBatchStepsTable : UUIDTable("traceability_batch_steps") {
|
||||
val batchId = reference("batch_id", TraceabilityBatchesTable)
|
||||
val templateNodeId = reference("template_node_id", TraceabilityTemplateNodesTable).nullable()
|
||||
val sort = integer("sort").default(0)
|
||||
val category = varchar("category", 32).default("business")
|
||||
val name = varchar("name", 120)
|
||||
val description = text("description").default("")
|
||||
val locked = bool("locked").default(false)
|
||||
val consumerVisible = bool("consumer_visible").default(true)
|
||||
val status = varchar("status", 32).default("pending")
|
||||
val operatorName = varchar("operator_name", 80).default("")
|
||||
val valuesJson = text("values_json").default("{}")
|
||||
val completedAt = timestamp("completed_at").nullable()
|
||||
val createdAt = timestamp("created_at").nullable()
|
||||
val updatedAt = timestamp("updated_at").nullable()
|
||||
}
|
||||
|
||||
object TraceabilityFeedbackTable : UUIDTable("traceability_feedback") {
|
||||
val batchId = reference("batch_id", TraceabilityBatchesTable)
|
||||
val type = varchar("type", 32).default("suggestion")
|
||||
val contact = varchar("contact", 120).default("")
|
||||
val content = text("content")
|
||||
val sourceType = varchar("source_type", 32).default("public")
|
||||
val rating = integer("rating").default(5)
|
||||
val createdAt = timestamp("created_at").nullable()
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package ink.snowflake.server.model.request
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class TraceFieldDefinitionRequest(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val type: String = "string",
|
||||
val required: Boolean = false,
|
||||
val visible: Boolean = true,
|
||||
val placeholder: String = "",
|
||||
val defaultValue: JsonElement? = null,
|
||||
val options: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceTemplateNodeRequest(
|
||||
val id: String? = null,
|
||||
val category: String = "business",
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val locked: Boolean = false,
|
||||
val consumerVisible: Boolean = true,
|
||||
val fields: List<TraceFieldDefinitionRequest> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SaveTraceTemplateRequest(
|
||||
val name: String,
|
||||
val description: String = "",
|
||||
val productName: String = "",
|
||||
val industryName: String = "",
|
||||
val coverImage: String = "",
|
||||
val themeColor: String = "#1f4fd6",
|
||||
val status: String = "draft",
|
||||
val nodes: List<TraceTemplateNodeRequest> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CreateTraceBatchRequest(
|
||||
val templateId: String,
|
||||
val batchName: String,
|
||||
val batchCode: String,
|
||||
val productName: String = "",
|
||||
val summary: String = "",
|
||||
val coverImage: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateTraceBatchBaseRequest(
|
||||
val batchName: String,
|
||||
val batchCode: String,
|
||||
val productName: String = "",
|
||||
val summary: String = "",
|
||||
val coverImage: String = "",
|
||||
val tags: List<String> = emptyList(),
|
||||
val currentStep: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateTraceBatchStepRequest(
|
||||
val operatorName: String = "",
|
||||
val status: String = "completed",
|
||||
val values: JsonObject = JsonObject(emptyMap()),
|
||||
val completedAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SubmitTraceabilityFeedbackRequest(
|
||||
val batchCode: String? = null,
|
||||
val batchId: String? = null,
|
||||
val type: String = "suggestion",
|
||||
val contact: String = "",
|
||||
val content: String,
|
||||
val source: String = "public",
|
||||
val rating: Int = 5,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityOssPresignRequest(
|
||||
val bucketName: String? = null,
|
||||
val objectName: String,
|
||||
val expiresMinutes: Int = 15,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityOssTempUrlRequest(
|
||||
val bucketName: String? = null,
|
||||
val objectName: String? = null,
|
||||
val objectDir: String? = null,
|
||||
val expiresSeconds: Int = 3600,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityOssMoveRequest(
|
||||
val bucketName: String? = null,
|
||||
val sourceObjectName: String,
|
||||
val targetObjectName: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityOssDeleteRequest(
|
||||
val bucketName: String? = null,
|
||||
val objectName: String,
|
||||
)
|
||||
|
||||
fun CreateTraceBatchRequest.templateUuid(): UUID = UUID.fromString(templateId)
|
||||
@@ -0,0 +1,155 @@
|
||||
package ink.snowflake.server.model.response
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityOverviewResponse(
|
||||
val templateCount: Int,
|
||||
val batchCount: Int,
|
||||
val publishedCount: Int,
|
||||
val feedbackCount: Int,
|
||||
val totalScans: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceFieldDefinitionResponse(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val type: String = "string",
|
||||
val required: Boolean = false,
|
||||
val visible: Boolean = true,
|
||||
val placeholder: String = "",
|
||||
val defaultValue: JsonElement? = null,
|
||||
val options: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceTemplateNodeResponse(
|
||||
val id: String,
|
||||
val sort: Int,
|
||||
val category: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val locked: Boolean = false,
|
||||
val consumerVisible: Boolean,
|
||||
val fields: List<TraceFieldDefinitionResponse>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceTemplateSummaryResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val productName: String,
|
||||
val industryName: String,
|
||||
val coverImage: String,
|
||||
val themeColor: String,
|
||||
val status: String,
|
||||
val nodeCount: Int,
|
||||
val batchCount: Int,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceTemplateDetailResponse(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val productName: String,
|
||||
val industryName: String,
|
||||
val coverImage: String,
|
||||
val themeColor: String,
|
||||
val status: String,
|
||||
val nodes: List<TraceTemplateNodeResponse>,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceBatchStepResponse(
|
||||
val id: String,
|
||||
val templateNodeId: String? = null,
|
||||
val sort: Int,
|
||||
val category: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val locked: Boolean = false,
|
||||
val consumerVisible: Boolean,
|
||||
val status: String,
|
||||
val operatorName: String,
|
||||
val values: JsonObject,
|
||||
val completedAt: String = "",
|
||||
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceBatchSummaryResponse(
|
||||
val id: String,
|
||||
val templateId: String,
|
||||
val templateName: String,
|
||||
val batchName: String,
|
||||
val batchCode: String,
|
||||
val productName: String,
|
||||
val summary: String,
|
||||
val coverImage: String,
|
||||
val tags: List<String>,
|
||||
val status: String,
|
||||
val currentStep: Int,
|
||||
val scanCount: Int,
|
||||
val publicUrl: String,
|
||||
val updatedAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceBatchDetailResponse(
|
||||
val id: String,
|
||||
val templateId: String,
|
||||
val templateName: String,
|
||||
val batchName: String,
|
||||
val batchCode: String,
|
||||
val productName: String,
|
||||
val summary: String,
|
||||
val coverImage: String,
|
||||
val tags: List<String>,
|
||||
val status: String,
|
||||
val currentStep: Int,
|
||||
val scanCount: Int,
|
||||
val publicUrl: String,
|
||||
val steps: List<TraceBatchStepResponse>,
|
||||
val updatedAt: String,
|
||||
val publishedAt: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityFeedbackResponse(
|
||||
val id: String,
|
||||
val batchId: String,
|
||||
val batchCode: String,
|
||||
val batchName: String,
|
||||
val type: String,
|
||||
val contact: String,
|
||||
val content: String,
|
||||
val source: String,
|
||||
val rating: Int,
|
||||
val createdAt: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityPublicDetailResponse(
|
||||
val batch: TraceBatchDetailResponse,
|
||||
val companySectionTitle: String = "企业公开资料",
|
||||
val publicSections: List<TraceBatchStepResponse>,
|
||||
val businessSections: List<TraceBatchStepResponse>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraceabilityOssFileResponse(
|
||||
val bucketName: String,
|
||||
val objectName: String,
|
||||
val uploadUrl: String? = null,
|
||||
val tempUrl: String? = null,
|
||||
val contentType: String? = null,
|
||||
val fileName: String? = null,
|
||||
val size: Long? = null,
|
||||
)
|
||||
@@ -16,4 +16,14 @@ class AppConfig(config: ApplicationConfig) {
|
||||
val smtpPort: Int = config.property("ktor.mail.smtp.port").getString().toInt()
|
||||
val smtpUser: String = config.property("ktor.mail.smtp.user").getString()
|
||||
val smtpPassword: String = config.property("ktor.mail.smtp.password").getString()
|
||||
|
||||
val ossEndpoint: String = config.property("ktor.oss.endpoint").getString()
|
||||
val ossPort: Int = config.property("ktor.oss.port").getString().toInt()
|
||||
val ossSecure: Boolean = config.property("ktor.oss.secure").getString().toBoolean()
|
||||
val ossRegion: String = config.property("ktor.oss.region").getString()
|
||||
val ossAccessKey: String = config.property("ktor.oss.access-key").getString()
|
||||
val ossSecretKey: String = config.property("ktor.oss.secret-key").getString()
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1,52 +1,162 @@
|
||||
package ink.snowflake.server.utils
|
||||
|
||||
import io.minio.BucketExistsArgs
|
||||
import io.minio.CopyObjectArgs
|
||||
import io.minio.CopySource
|
||||
import io.minio.GetPresignedObjectUrlArgs
|
||||
import io.minio.MakeBucketArgs
|
||||
import io.minio.MinioClient
|
||||
import io.minio.PutObjectArgs
|
||||
import io.minio.GetPresignedObjectUrlArgs
|
||||
import io.minio.RemoveObjectArgs
|
||||
import io.minio.http.Method
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.InputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object OSSUtils {
|
||||
private lateinit var appConfig: AppConfig
|
||||
private val client: MinioClient by lazy {
|
||||
MinioClient.builder()
|
||||
.endpoint(appConfig.ossEndpoint, appConfig.ossPort, appConfig.ossSecure)
|
||||
.region(appConfig.ossRegion)
|
||||
.credentials(appConfig.ossAccessKey, appConfig.ossSecretKey)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val client: MinioClient = MinioClient.builder()
|
||||
.endpoint("ai.ronsunny.cn",9000,true) // 你的MinIO地址
|
||||
.region("Chengdu")
|
||||
.credentials("minioadmin", "minioadmin") // 账号密码
|
||||
.build()
|
||||
fun init(config: AppConfig) {
|
||||
appConfig = config
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param bucket 桶名
|
||||
* @param objName 对象名(路径也放这里,例如 "images/test.png")
|
||||
* @param input 输入流
|
||||
* @param size 文件大小(字节)
|
||||
* @param contentType 文件MIME类型,比如 "image/png"
|
||||
*/
|
||||
fun uploadFile(bucket: String, objName: String, input: InputStream, size: Long, contentType: String) {
|
||||
fun pushFile(
|
||||
bucketName: String,
|
||||
objectName: String,
|
||||
fileBytes: ByteArray,
|
||||
contentType: String,
|
||||
) {
|
||||
ensureBucketExists(bucketName)
|
||||
ByteArrayInputStream(fileBytes).use { input ->
|
||||
uploadFile(bucketName, objectName, input, fileBytes.size.toLong(), contentType)
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadFile(
|
||||
bucketName: String,
|
||||
objectName: String,
|
||||
input: InputStream,
|
||||
size: Long,
|
||||
contentType: String,
|
||||
) {
|
||||
ensureBucketExists(bucketName)
|
||||
client.putObject(
|
||||
PutObjectArgs.builder()
|
||||
.bucket(bucket)
|
||||
.`object`(objName)
|
||||
.stream(input, size, -1) // -1 表示不限制分片大小,MinIO自己切
|
||||
.bucket(bucketName)
|
||||
.`object`(objectName)
|
||||
.stream(input, size, -1)
|
||||
.contentType(contentType)
|
||||
.build()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时访问地址
|
||||
* @param bucket 桶名
|
||||
* @param objName 对象名
|
||||
* @param expiryMinutes 过期时间,分钟
|
||||
*/
|
||||
fun getPresignedUrl(bucket: String, objName: String, expiryMinutes: Int = 15): String {
|
||||
fun getUploadToken(
|
||||
bucketName: String,
|
||||
objectName: String,
|
||||
expiryMinutes: Int = 15,
|
||||
): String {
|
||||
ensureBucketExists(bucketName)
|
||||
return client.getPresignedObjectUrl(
|
||||
GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.PUT)
|
||||
.bucket(bucketName)
|
||||
.`object`(objectName)
|
||||
.expiry(expiryMinutes, TimeUnit.MINUTES)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getPresignedUrl(
|
||||
bucket: String,
|
||||
objName: String,
|
||||
expiryMinutes: Int = 15,
|
||||
): String = getTempUrl(bucket, objName, expiryMinutes * 60)
|
||||
|
||||
fun getTempUrl(
|
||||
bucketName: String?,
|
||||
objectName: String?,
|
||||
seconds: Int = 3600,
|
||||
): String {
|
||||
val actualBucket = bucketName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackBucket
|
||||
val actualObject = objectName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackObject
|
||||
ensureBucketExists(actualBucket)
|
||||
return client.getPresignedObjectUrl(
|
||||
GetPresignedObjectUrlArgs.builder()
|
||||
.method(Method.GET)
|
||||
.bucket(bucket)
|
||||
.`object`(objName)
|
||||
.expiry(expiryMinutes, TimeUnit.MINUTES)
|
||||
.build()
|
||||
.bucket(actualBucket)
|
||||
.`object`(actualObject)
|
||||
.expiry(seconds, TimeUnit.SECONDS)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun getTempUrlDict(
|
||||
bucketName: String?,
|
||||
objectDir: String?,
|
||||
objectName: String?,
|
||||
seconds: Int = 3600,
|
||||
): String {
|
||||
val actualBucket = bucketName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackBucket
|
||||
val actualDir = objectDir?.takeIf { it.isNotBlank() }
|
||||
val actualObject = objectName?.takeIf { it.isNotBlank() } ?: appConfig.ossFallbackObject
|
||||
val fullObjectName = listOfNotNull(actualDir, actualObject).joinToString("/")
|
||||
return getTempUrl(actualBucket, fullObjectName, seconds)
|
||||
}
|
||||
|
||||
fun moveFile(
|
||||
bucketName: String,
|
||||
sourceObjectName: String,
|
||||
targetObjectName: String,
|
||||
) {
|
||||
ensureBucketExists(bucketName)
|
||||
client.copyObject(
|
||||
CopyObjectArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.`object`(targetObjectName)
|
||||
.source(
|
||||
CopySource.builder()
|
||||
.bucket(bucketName)
|
||||
.`object`(sourceObjectName)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
deleteFile(bucketName, sourceObjectName)
|
||||
}
|
||||
|
||||
fun deleteFile(
|
||||
bucketName: String,
|
||||
objectName: String,
|
||||
) {
|
||||
client.removeObject(
|
||||
RemoveObjectArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.`object`(objectName)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun defaultBucket(): String = appConfig.ossDefaultBucket
|
||||
|
||||
private fun ensureBucketExists(bucketName: String) {
|
||||
val exists = client.bucketExists(
|
||||
BucketExistsArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.build(),
|
||||
)
|
||||
if (!exists) {
|
||||
client.makeBucket(
|
||||
MakeBucketArgs.builder()
|
||||
.bucket(bucketName)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
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.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.SubmitTraceabilityFeedbackRequest
|
||||
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
|
||||
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
|
||||
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
|
||||
import ink.snowflake.server.model.request.templateUuid
|
||||
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.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.TraceabilityOverviewResponse
|
||||
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.jetbrains.exposed.v1.core.SortOrder
|
||||
import org.jetbrains.exposed.v1.core.and
|
||||
import org.jetbrains.exposed.v1.core.SqlExpressionBuilder.eq
|
||||
import org.jetbrains.exposed.v1.datetime.timestampLiteral
|
||||
import org.jetbrains.exposed.v1.jdbc.SchemaUtils
|
||||
import org.jetbrains.exposed.v1.jdbc.deleteWhere
|
||||
import org.jetbrains.exposed.v1.jdbc.insertAndGetId
|
||||
import org.jetbrains.exposed.v1.jdbc.selectAll
|
||||
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
|
||||
import org.jetbrains.exposed.v1.jdbc.update
|
||||
import java.util.UUID
|
||||
|
||||
object TraceabilityDao {
|
||||
private val json = Json {
|
||||
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"
|
||||
|
||||
fun initSchema() {
|
||||
transaction {
|
||||
SchemaUtils.createMissingTablesAndColumns(
|
||||
TraceabilityTemplatesTable,
|
||||
TraceabilityTemplateNodesTable,
|
||||
TraceabilityBatchesTable,
|
||||
TraceabilityBatchStepsTable,
|
||||
TraceabilityFeedbackTable,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getOverview(): TraceabilityOverviewResponse = transaction {
|
||||
TraceabilityOverviewResponse(
|
||||
templateCount = TraceabilityTemplatesTable.selectAll().count().toInt(),
|
||||
batchCount = TraceabilityBatchesTable.selectAll().count().toInt(),
|
||||
publishedCount = TraceabilityBatchesTable.selectAll()
|
||||
.where { TraceabilityBatchesTable.status eq "published" }
|
||||
.count()
|
||||
.toInt(),
|
||||
feedbackCount = TraceabilityFeedbackTable.selectAll().count().toInt(),
|
||||
totalScans = TraceabilityBatchesTable.selectAll().sumOf { it[TraceabilityBatchesTable.scanCount] },
|
||||
)
|
||||
}
|
||||
|
||||
private fun nowInstant() = Clock.System.now()
|
||||
|
||||
fun listTemplates(): List<TraceTemplateSummaryResponse> = transaction {
|
||||
val batchCountByTemplate = TraceabilityBatchesTable.selectAll()
|
||||
.groupBy { it[TraceabilityBatchesTable.templateId].value }
|
||||
.mapValues { (_, rows) -> rows.size }
|
||||
val nodeCountByTemplate = TraceabilityTemplateNodesTable.selectAll()
|
||||
.groupBy { it[TraceabilityTemplateNodesTable.templateId].value }
|
||||
.mapValues { (_, rows) -> rows.size }
|
||||
|
||||
TraceabilityTemplatesTable.selectAll()
|
||||
.orderBy(TraceabilityTemplatesTable.updatedAt, SortOrder.DESC)
|
||||
.map {
|
||||
TraceTemplateSummaryResponse(
|
||||
id = it[TraceabilityTemplatesTable.id].value.toString(),
|
||||
name = it[TraceabilityTemplatesTable.name],
|
||||
description = it[TraceabilityTemplatesTable.description],
|
||||
productName = it[TraceabilityTemplatesTable.productName],
|
||||
industryName = it[TraceabilityTemplatesTable.industryName],
|
||||
coverImage = it[TraceabilityTemplatesTable.coverImage],
|
||||
themeColor = it[TraceabilityTemplatesTable.themeColor],
|
||||
status = it[TraceabilityTemplatesTable.status],
|
||||
nodeCount = nodeCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
|
||||
batchCount = batchCountByTemplate[it[TraceabilityTemplatesTable.id].value] ?: 0,
|
||||
updatedAt = formatTimestamp(it[TraceabilityTemplatesTable.updatedAt]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getTemplate(templateId: UUID): TraceTemplateDetailResponse? = transaction {
|
||||
val templateRow = TraceabilityTemplatesTable.selectAll()
|
||||
.where { TraceabilityTemplatesTable.id eq templateId }
|
||||
.singleOrNull() ?: return@transaction null
|
||||
|
||||
TraceTemplateDetailResponse(
|
||||
id = templateRow[TraceabilityTemplatesTable.id].value.toString(),
|
||||
name = templateRow[TraceabilityTemplatesTable.name],
|
||||
description = templateRow[TraceabilityTemplatesTable.description],
|
||||
productName = templateRow[TraceabilityTemplatesTable.productName],
|
||||
industryName = templateRow[TraceabilityTemplatesTable.industryName],
|
||||
coverImage = templateRow[TraceabilityTemplatesTable.coverImage],
|
||||
themeColor = templateRow[TraceabilityTemplatesTable.themeColor],
|
||||
status = templateRow[TraceabilityTemplatesTable.status],
|
||||
nodes = loadTemplateNodes(templateId),
|
||||
updatedAt = formatTimestamp(templateRow[TraceabilityTemplatesTable.updatedAt]),
|
||||
)
|
||||
}
|
||||
|
||||
fun saveTemplate(templateId: UUID?, request: SaveTraceTemplateRequest): TraceTemplateDetailResponse = transaction {
|
||||
val now = timestampLiteral(nowInstant())
|
||||
val currentId = templateId ?: TraceabilityTemplatesTable.insertAndGetId {
|
||||
it[name] = request.name
|
||||
it[description] = request.description
|
||||
it[productName] = request.productName
|
||||
it[industryName] = request.industryName
|
||||
it[coverImage] = request.coverImage
|
||||
it[themeColor] = request.themeColor
|
||||
it[status] = request.status
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}.value
|
||||
|
||||
if (templateId != null) {
|
||||
TraceabilityTemplatesTable.update({ TraceabilityTemplatesTable.id eq currentId }) {
|
||||
it[name] = request.name
|
||||
it[description] = request.description
|
||||
it[productName] = request.productName
|
||||
it[industryName] = request.industryName
|
||||
it[coverImage] = request.coverImage
|
||||
it[themeColor] = request.themeColor
|
||||
it[status] = request.status
|
||||
it[updatedAt] = now
|
||||
}
|
||||
val existingNodeIds = TraceabilityTemplateNodesTable.selectAll()
|
||||
.where { TraceabilityTemplateNodesTable.templateId eq currentId }
|
||||
.map { it[TraceabilityTemplateNodesTable.id].value }
|
||||
if (existingNodeIds.isNotEmpty()) {
|
||||
existingNodeIds.forEach { nodeId ->
|
||||
TraceabilityBatchStepsTable.deleteWhere {
|
||||
TraceabilityBatchStepsTable.templateNodeId eq nodeId
|
||||
}
|
||||
}
|
||||
}
|
||||
TraceabilityTemplateNodesTable.deleteWhere { TraceabilityTemplateNodesTable.templateId eq currentId }
|
||||
}
|
||||
|
||||
request.nodes.forEachIndexed { index, node ->
|
||||
TraceabilityTemplateNodesTable.insertAndGetId {
|
||||
it[this.templateId] = currentId
|
||||
it[sort] = index
|
||||
it[category] = node.category
|
||||
it[name] = node.name
|
||||
it[description] = node.description
|
||||
it[locked] = node.locked
|
||||
it[consumerVisible] = node.consumerVisible
|
||||
it[fieldsJson] = json.encodeToString(node.fields)
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
|
||||
getTemplate(currentId)!!
|
||||
}
|
||||
|
||||
fun deleteTemplate(templateId: UUID): Boolean = transaction {
|
||||
val batchIds = TraceabilityBatchesTable.selectAll()
|
||||
.where { TraceabilityBatchesTable.templateId eq templateId }
|
||||
.map { it[TraceabilityBatchesTable.id].value }
|
||||
if (batchIds.isNotEmpty()) {
|
||||
batchIds.forEach { batchId ->
|
||||
TraceabilityFeedbackTable.deleteWhere { TraceabilityFeedbackTable.batchId eq batchId }
|
||||
TraceabilityBatchStepsTable.deleteWhere { TraceabilityBatchStepsTable.batchId eq batchId }
|
||||
TraceabilityBatchesTable.deleteWhere { TraceabilityBatchesTable.id eq batchId }
|
||||
}
|
||||
}
|
||||
TraceabilityTemplateNodesTable.deleteWhere { TraceabilityTemplateNodesTable.templateId eq templateId }
|
||||
TraceabilityTemplatesTable.deleteWhere { TraceabilityTemplatesTable.id eq templateId } > 0
|
||||
}
|
||||
|
||||
fun deleteBatch(batchId: UUID): Boolean = transaction {
|
||||
TraceabilityFeedbackTable.deleteWhere { TraceabilityFeedbackTable.batchId eq batchId }
|
||||
TraceabilityBatchStepsTable.deleteWhere { TraceabilityBatchStepsTable.batchId eq batchId }
|
||||
TraceabilityBatchesTable.deleteWhere { TraceabilityBatchesTable.id eq batchId } > 0
|
||||
}
|
||||
|
||||
fun listBatches(): List<TraceBatchSummaryResponse> = transaction {
|
||||
val templateNames = TraceabilityTemplatesTable.selectAll()
|
||||
.associate { it[TraceabilityTemplatesTable.id].value to it[TraceabilityTemplatesTable.name] }
|
||||
|
||||
TraceabilityBatchesTable.selectAll()
|
||||
.orderBy(TraceabilityBatchesTable.updatedAt, SortOrder.DESC)
|
||||
.map {
|
||||
val code = it[TraceabilityBatchesTable.batchCode]
|
||||
TraceBatchSummaryResponse(
|
||||
id = it[TraceabilityBatchesTable.id].value.toString(),
|
||||
templateId = it[TraceabilityBatchesTable.templateId].value.toString(),
|
||||
templateName = templateNames[it[TraceabilityBatchesTable.templateId].value] ?: "",
|
||||
batchName = it[TraceabilityBatchesTable.batchName],
|
||||
batchCode = code,
|
||||
productName = it[TraceabilityBatchesTable.productName],
|
||||
summary = it[TraceabilityBatchesTable.summary],
|
||||
coverImage = it[TraceabilityBatchesTable.coverImage],
|
||||
tags = decodeStringList(it[TraceabilityBatchesTable.tagsJson]),
|
||||
status = it[TraceabilityBatchesTable.status],
|
||||
currentStep = it[TraceabilityBatchesTable.currentStep],
|
||||
scanCount = it[TraceabilityBatchesTable.scanCount],
|
||||
publicUrl = publicUrl(code),
|
||||
updatedAt = formatTimestamp(it[TraceabilityBatchesTable.updatedAt]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getBatch(batchId: UUID): TraceBatchDetailResponse? = transaction {
|
||||
val batchRow = TraceabilityBatchesTable.selectAll()
|
||||
.where { TraceabilityBatchesTable.id eq batchId }
|
||||
.singleOrNull() ?: return@transaction null
|
||||
val template = TraceabilityTemplatesTable.selectAll()
|
||||
.where { TraceabilityTemplatesTable.id eq batchRow[TraceabilityBatchesTable.templateId] }
|
||||
.single()
|
||||
val code = batchRow[TraceabilityBatchesTable.batchCode]
|
||||
|
||||
TraceBatchDetailResponse(
|
||||
id = batchRow[TraceabilityBatchesTable.id].value.toString(),
|
||||
templateId = batchRow[TraceabilityBatchesTable.templateId].value.toString(),
|
||||
templateName = template[TraceabilityTemplatesTable.name],
|
||||
batchName = batchRow[TraceabilityBatchesTable.batchName],
|
||||
batchCode = code,
|
||||
productName = batchRow[TraceabilityBatchesTable.productName],
|
||||
summary = batchRow[TraceabilityBatchesTable.summary],
|
||||
coverImage = batchRow[TraceabilityBatchesTable.coverImage],
|
||||
tags = decodeStringList(batchRow[TraceabilityBatchesTable.tagsJson]),
|
||||
status = batchRow[TraceabilityBatchesTable.status],
|
||||
currentStep = batchRow[TraceabilityBatchesTable.currentStep],
|
||||
scanCount = batchRow[TraceabilityBatchesTable.scanCount],
|
||||
publicUrl = publicUrl(code),
|
||||
steps = loadBatchSteps(batchId),
|
||||
updatedAt = formatTimestamp(batchRow[TraceabilityBatchesTable.updatedAt]),
|
||||
publishedAt = formatTimestamp(batchRow[TraceabilityBatchesTable.publishedAt]),
|
||||
)
|
||||
}
|
||||
|
||||
fun createBatch(request: CreateTraceBatchRequest): TraceBatchDetailResponse = transaction {
|
||||
val template = getTemplate(request.templateUuid()) ?: error("template not found")
|
||||
val now = timestampLiteral(nowInstant())
|
||||
val batchId = TraceabilityBatchesTable.insertAndGetId {
|
||||
it[this.templateId] = request.templateUuid()
|
||||
it[batchName] = request.batchName
|
||||
it[batchCode] = request.batchCode
|
||||
it[productName] = request.productName
|
||||
it[summary] = request.summary
|
||||
it[coverImage] = request.coverImage
|
||||
it[tagsJson] = json.encodeToString(request.tags)
|
||||
it[status] = "draft"
|
||||
it[currentStep] = 0
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}.value
|
||||
|
||||
template.nodes.forEach { node ->
|
||||
TraceabilityBatchStepsTable.insertAndGetId {
|
||||
it[this.batchId] = batchId
|
||||
it[this.templateNodeId] = UUID.fromString(node.id)
|
||||
it[sort] = node.sort
|
||||
it[category] = node.category
|
||||
it[name] = node.name
|
||||
it[description] = node.description
|
||||
it[locked] = node.locked
|
||||
it[consumerVisible] = node.consumerVisible
|
||||
it[status] = "pending"
|
||||
it[operatorName] = ""
|
||||
it[valuesJson] = buildDefaultValues(node.fields)
|
||||
it[createdAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
}
|
||||
|
||||
getBatch(batchId)!!
|
||||
}
|
||||
|
||||
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
|
||||
val now = timestampLiteral(nowInstant())
|
||||
val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
|
||||
it[batchName] = request.batchName
|
||||
it[batchCode] = request.batchCode
|
||||
it[productName] = request.productName
|
||||
it[summary] = request.summary
|
||||
it[coverImage] = request.coverImage
|
||||
it[tagsJson] = json.encodeToString(request.tags)
|
||||
it[currentStep] = request.currentStep
|
||||
it[updatedAt] = now
|
||||
}
|
||||
if (updated == 0) return@transaction null
|
||||
getBatch(batchId)
|
||||
}
|
||||
|
||||
fun updateBatchStep(batchId: UUID, stepId: UUID, request: UpdateTraceBatchStepRequest): TraceBatchDetailResponse? = transaction {
|
||||
val now = timestampLiteral(nowInstant())
|
||||
val completedAt = request.completedAt
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { timestampLiteral(Instant.parse(it)) }
|
||||
?: now
|
||||
|
||||
val updated = TraceabilityBatchStepsTable.update({
|
||||
(TraceabilityBatchStepsTable.batchId eq batchId) and (TraceabilityBatchStepsTable.id eq stepId)
|
||||
}) {
|
||||
it[operatorName] = request.operatorName
|
||||
it[status] = request.status
|
||||
it[valuesJson] = json.encodeToString(request.values)
|
||||
it[this.completedAt] = completedAt
|
||||
it[updatedAt] = now
|
||||
}
|
||||
if (updated == 0) return@transaction null
|
||||
|
||||
val stepRows = TraceabilityBatchStepsTable.selectAll()
|
||||
.where { TraceabilityBatchStepsTable.batchId eq batchId }
|
||||
.orderBy(TraceabilityBatchStepsTable.sort, SortOrder.ASC)
|
||||
.toList()
|
||||
val currentIndex = stepRows.indexOfFirst { it[TraceabilityBatchStepsTable.status] != "completed" }
|
||||
.let { if (it == -1) (stepRows.size - 1).coerceAtLeast(0) else it }
|
||||
|
||||
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
|
||||
it[currentStep] = currentIndex
|
||||
it[updatedAt] = now
|
||||
}
|
||||
getBatch(batchId)
|
||||
}
|
||||
|
||||
fun publishBatch(batchId: UUID): TraceBatchDetailResponse? = transaction {
|
||||
val now = timestampLiteral(nowInstant())
|
||||
val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
|
||||
it[status] = "published"
|
||||
it[publishedAt] = now
|
||||
it[updatedAt] = now
|
||||
}
|
||||
if (updated == 0) return@transaction null
|
||||
getBatch(batchId)
|
||||
}
|
||||
|
||||
fun getPublicDetailByCode(batchCode: String, increaseScan: Boolean = false): TraceabilityPublicDetailResponse? = transaction {
|
||||
val batchRow = TraceabilityBatchesTable.selectAll()
|
||||
.where { TraceabilityBatchesTable.batchCode eq batchCode }
|
||||
.singleOrNull() ?: return@transaction null
|
||||
|
||||
if (increaseScan) {
|
||||
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchRow[TraceabilityBatchesTable.id].value }) {
|
||||
it[scanCount] = batchRow[TraceabilityBatchesTable.scanCount] + 1
|
||||
it[updatedAt] = timestampLiteral(nowInstant())
|
||||
}
|
||||
}
|
||||
|
||||
val batch = getBatch(batchRow[TraceabilityBatchesTable.id].value) ?: return@transaction null
|
||||
TraceabilityPublicDetailResponse(
|
||||
batch = batch,
|
||||
publicSections = batch.steps.filter { it.category == "public" && it.consumerVisible },
|
||||
businessSections = batch.steps.filter { it.category != "public" && it.consumerVisible },
|
||||
)
|
||||
}
|
||||
|
||||
fun listFeedback(): List<TraceabilityFeedbackResponse> = transaction {
|
||||
val batchMap = TraceabilityBatchesTable.selectAll()
|
||||
.associateBy { it[TraceabilityBatchesTable.id].value }
|
||||
|
||||
TraceabilityFeedbackTable.selectAll()
|
||||
.orderBy(TraceabilityFeedbackTable.createdAt, SortOrder.DESC)
|
||||
.map {
|
||||
val batch = batchMap[it[TraceabilityFeedbackTable.batchId].value]
|
||||
TraceabilityFeedbackResponse(
|
||||
id = it[TraceabilityFeedbackTable.id].value.toString(),
|
||||
batchId = it[TraceabilityFeedbackTable.batchId].value.toString(),
|
||||
batchCode = batch?.get(TraceabilityBatchesTable.batchCode) ?: "",
|
||||
batchName = batch?.get(TraceabilityBatchesTable.batchName) ?: "",
|
||||
type = it[TraceabilityFeedbackTable.type],
|
||||
contact = it[TraceabilityFeedbackTable.contact],
|
||||
content = it[TraceabilityFeedbackTable.content],
|
||||
source = it[TraceabilityFeedbackTable.sourceType],
|
||||
rating = it[TraceabilityFeedbackTable.rating],
|
||||
createdAt = formatTimestamp(it[TraceabilityFeedbackTable.createdAt]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): TraceabilityFeedbackResponse = transaction {
|
||||
val batchId = when {
|
||||
!request.batchId.isNullOrBlank() -> UUID.fromString(request.batchId)
|
||||
!request.batchCode.isNullOrBlank() -> TraceabilityBatchesTable.selectAll()
|
||||
.where { TraceabilityBatchesTable.batchCode eq request.batchCode }
|
||||
.single()[TraceabilityBatchesTable.id].value
|
||||
else -> error("batch not found")
|
||||
}
|
||||
|
||||
val now = timestampLiteral(nowInstant())
|
||||
val feedbackId = TraceabilityFeedbackTable.insertAndGetId {
|
||||
it[this.batchId] = batchId
|
||||
it[type] = request.type
|
||||
it[contact] = request.contact
|
||||
it[content] = request.content
|
||||
it[sourceType] = request.source
|
||||
it[rating] = request.rating.coerceIn(1, 5)
|
||||
it[createdAt] = now
|
||||
}.value
|
||||
|
||||
listFeedback().first { it.id == feedbackId.toString() }
|
||||
}
|
||||
|
||||
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
|
||||
return TraceabilityTemplateNodesTable.selectAll()
|
||||
.where { TraceabilityTemplateNodesTable.templateId eq templateId }
|
||||
.orderBy(TraceabilityTemplateNodesTable.sort, SortOrder.ASC)
|
||||
.map {
|
||||
TraceTemplateNodeResponse(
|
||||
id = it[TraceabilityTemplateNodesTable.id].value.toString(),
|
||||
sort = it[TraceabilityTemplateNodesTable.sort],
|
||||
category = it[TraceabilityTemplateNodesTable.category],
|
||||
name = it[TraceabilityTemplateNodesTable.name],
|
||||
description = it[TraceabilityTemplateNodesTable.description],
|
||||
locked = it[TraceabilityTemplateNodesTable.locked],
|
||||
consumerVisible = it[TraceabilityTemplateNodesTable.consumerVisible],
|
||||
fields = decodeFields(it[TraceabilityTemplateNodesTable.fieldsJson]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 ->
|
||||
TraceabilityTemplateNodesTable.selectAll()
|
||||
.where { TraceabilityTemplateNodesTable.id eq nodeId }
|
||||
.singleOrNull()
|
||||
?.let { decodeFields(it[TraceabilityTemplateNodesTable.fieldsJson]) }
|
||||
} ?: emptyList()
|
||||
|
||||
TraceBatchStepResponse(
|
||||
id = row[TraceabilityBatchStepsTable.id].value.toString(),
|
||||
templateNodeId = row[TraceabilityBatchStepsTable.templateNodeId]?.value?.toString(),
|
||||
sort = row[TraceabilityBatchStepsTable.sort],
|
||||
category = row[TraceabilityBatchStepsTable.category],
|
||||
name = row[TraceabilityBatchStepsTable.name],
|
||||
description = row[TraceabilityBatchStepsTable.description],
|
||||
locked = row[TraceabilityBatchStepsTable.locked],
|
||||
consumerVisible = row[TraceabilityBatchStepsTable.consumerVisible],
|
||||
status = row[TraceabilityBatchStepsTable.status],
|
||||
operatorName = row[TraceabilityBatchStepsTable.operatorName],
|
||||
values = decodeValues(row[TraceabilityBatchStepsTable.valuesJson]),
|
||||
completedAt = formatTimestamp(row[TraceabilityBatchStepsTable.completedAt]),
|
||||
fields = fields,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeFields(raw: String): List<TraceFieldDefinitionResponse> {
|
||||
return json.decodeFromString<List<TraceFieldDefinitionRequest>>(raw).map {
|
||||
TraceFieldDefinitionResponse(
|
||||
key = it.key,
|
||||
label = it.label,
|
||||
type = it.type,
|
||||
required = it.required,
|
||||
visible = it.visible,
|
||||
placeholder = it.placeholder,
|
||||
defaultValue = it.defaultValue,
|
||||
options = it.options,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeValues(raw: String): JsonObject = try {
|
||||
json.decodeFromString<JsonObject>(raw)
|
||||
} catch (_: Exception) {
|
||||
JsonObject(emptyMap())
|
||||
}
|
||||
|
||||
private fun decodeStringList(raw: String): List<String> = try {
|
||||
json.decodeFromString<List<String>>(raw)
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun buildDefaultValues(fields: List<TraceFieldDefinitionResponse>): String {
|
||||
val values = buildJsonObject {
|
||||
fields.forEach { field ->
|
||||
put(field.key, field.defaultValue ?: JsonNull)
|
||||
}
|
||||
}
|
||||
return json.encodeToString(values)
|
||||
}
|
||||
|
||||
private fun formatTimestamp(value: Instant?): String =
|
||||
value?.toString()?.replace('T', ' ')?.replace("Z", "") ?: ""
|
||||
|
||||
private fun publicUrl(code: String): String {
|
||||
return "$publicPreviewBaseUrl/p/$code"
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,14 @@ ktor:
|
||||
user: "account@snowflake.ink"
|
||||
password: "7ZYPc75xCViqSrCg"
|
||||
|
||||
oss:
|
||||
endpoint: "ai.ronsunny.cn"
|
||||
port: 9000
|
||||
secure: true
|
||||
region: "Chengdu"
|
||||
access-key: "minioadmin"
|
||||
secret-key: "minioadmin"
|
||||
default-bucket: "traceability"
|
||||
fallback-bucket: "system"
|
||||
fallback-object: "favicon.ico"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user