溯源系统初版

This commit is contained in:
BBIT-Kai
2026-04-10 18:51:00 +08:00
parent 5971791038
commit 0a43f5e4b9
40 changed files with 7910 additions and 30 deletions
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
@@ -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"
}
}
+11
View File
@@ -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"