溯源系统初版

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
+36
View File
@@ -0,0 +1,36 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
+41
View File
@@ -0,0 +1,41 @@
val kotlin_version: String by project
val logback_version: String by project
plugins {
kotlin("jvm") version "2.3.0"
id("io.ktor.plugin") version "3.4.2"
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0"
}
group = "com.bbitcn"
version = "0.0.1"
application {
mainClass = "io.ktor.server.netty.EngineMain"
}
kotlin {
jvmToolchain(21)
}
dependencies {
implementation("io.ktor:ktor-server-cors")
implementation("io.ktor:ktor-server-default-headers")
implementation("io.ktor:ktor-server-core")
implementation("io.ktor:ktor-server-host-common")
implementation("io.ktor:ktor-server-status-pages")
implementation("io.ktor:ktor-server-compression")
implementation("io.ktor:ktor-server-caching-headers")
implementation("io.ktor:ktor-server-content-negotiation")
implementation("io.ktor:ktor-serialization-kotlinx-json")
implementation("io.ktor:ktor-server-freemarker")
implementation("io.ktor:ktor-client-core")
implementation("io.ktor:ktor-client-cio")
implementation("io.ktor:ktor-client-content-negotiation")
implementation("io.ktor:ktor-client-logging")
implementation("io.ktor:ktor-server-netty")
implementation("ch.qos.logback:logback-classic:$logback_version")
implementation("io.ktor:ktor-server-config-yaml")
testImplementation("io.ktor:ktor-server-test-host")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}
+4
View File
@@ -0,0 +1,4 @@
kotlin.code.style=official
kotlin_version=2.3.0
ktor_version=3.4.2
logback_version=1.4.14
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+7
View File
@@ -0,0 +1,7 @@
rootProject.name = "f10"
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
+24
View File
@@ -0,0 +1,24 @@
package com.bbitcn
import io.ktor.server.application.*
fun main(args: Array<String>) {
io.ktor.server.netty.EngineMain.main(args)
}
fun Application.module() {
val traceabilityConfig = environment.config.toTraceabilityPublicConfig()
val traceabilityClient = TraceabilityClient(traceabilityConfig.coreBaseUrl)
val traceabilityService = TraceabilityService(traceabilityConfig, traceabilityClient)
monitor.subscribe(ApplicationStopped) {
traceabilityClient.close()
}
attributes.put(TraceabilityAttributes.ServiceKey, traceabilityService)
configureHTTP()
configureSerialization()
configureTemplating()
configureRouting()
}
+27
View File
@@ -0,0 +1,27 @@
package com.bbitcn
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.cachingheaders.CachingHeaders
import io.ktor.server.plugins.compression.Compression
import io.ktor.server.plugins.cors.routing.CORS
import io.ktor.server.plugins.defaultheaders.DefaultHeaders
fun Application.configureHTTP() {
install(CORS) {
allowMethod(HttpMethod.Get)
allowMethod(HttpMethod.Post)
allowHeader(HttpHeaders.ContentType)
anyHost()
}
install(DefaultHeaders) {
header("X-Service", "traceability-public")
}
install(Compression)
install(CachingHeaders)
}
+104
View File
@@ -0,0 +1,104 @@
package com.bbitcn
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.application.call
import io.ktor.server.freemarker.FreeMarkerContent
import io.ktor.server.http.content.staticResources
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receiveParameters
import io.ktor.server.response.respond
import io.ktor.server.response.respondRedirect
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.util.AttributeKey
object TraceabilityAttributes {
val ServiceKey = AttributeKey<TraceabilityService>("traceability.service")
}
fun Application.configureRouting() {
install(StatusPages) {
exception<Throwable> { call, cause ->
this@configureRouting.environment.log.error("Public page error", cause)
call.respondText("服务异常,请稍后重试", status = HttpStatusCode.InternalServerError)
}
}
routing {
get("/") {
call.respondText("traceability public server ok")
}
get("/health") {
call.respond(mapOf("status" to "ok"))
}
get("/p/{code}") {
val code = call.parameters["code"]?.trim().orEmpty()
if (code.isBlank()) {
call.respondText("批次编码不能为空", status = HttpStatusCode.BadRequest)
return@get
}
val page = call.traceabilityService().loadPage(code)
if (page == null) {
call.respond(
HttpStatusCode.NotFound,
FreeMarkerContent(
"error.ftl",
mapOf("message" to "未找到对应的溯源批次,请确认二维码或编码是否正确。"),
),
)
return@get
}
val result = call.request.queryParameters["result"].orEmpty()
val message = when (result) {
"success" -> "反馈已提交,感谢你的建议。"
"failed" -> "提交失败,请稍后再试。"
else -> ""
}
call.respond(
FreeMarkerContent(
"traceability.ftl",
mapOf(
"page" to page,
"feedbackMessage" to message,
),
),
)
}
post("/feedback") {
val params = call.receiveParameters()
val code = params["batchCode"]?.trim().orEmpty()
val content = params["content"]?.trim().orEmpty()
if (code.isBlank() || content.isBlank()) {
call.respondText("批次编码和反馈内容不能为空", status = HttpStatusCode.BadRequest)
return@post
}
val response = call.traceabilityService().submitFeedback(
code = code,
type = params["type"].orEmpty(),
contact = params["contact"].orEmpty(),
content = content,
rating = params["rating"]?.toIntOrNull() ?: 5,
)
val result = if (response.status) "success" else "failed"
call.respondRedirect("/p/$code?result=$result")
}
staticResources("/static", "static")
}
}
private fun ApplicationCall.traceabilityService(): TraceabilityService {
return application.attributes[TraceabilityAttributes.ServiceKey]
}
+18
View File
@@ -0,0 +1,18 @@
package com.bbitcn
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import kotlinx.serialization.json.Json
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
encodeDefaults = true
},
)
}
}
+12
View File
@@ -0,0 +1,12 @@
package com.bbitcn
import freemarker.cache.ClassTemplateLoader
import io.ktor.server.application.Application
import io.ktor.server.application.install
import io.ktor.server.freemarker.FreeMarker
fun Application.configureTemplating() {
install(FreeMarker) {
templateLoader = ClassTemplateLoader(this::class.java.classLoader, "templates")
}
}
+75
View File
@@ -0,0 +1,75 @@
package com.bbitcn
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.isSuccess
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class TraceabilityClient(
private val coreBaseUrl: String,
) {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val client = HttpClient(CIO) {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
level = LogLevel.INFO
}
}
suspend fun fetchPublicDetail(
code: String,
increaseScan: Boolean,
): TraceabilityPublicDetailResponse? {
val response = client.get {
url("$coreBaseUrl/traceability/public/by-code/$code")
parameter("increaseScan", increaseScan)
accept(ContentType.Application.Json)
}
if (!response.status.isSuccess()) {
return null
}
val payload = response.body<ApiResponse<TraceabilityPublicDetailResponse>>()
return payload.data
}
suspend fun submitFeedback(request: SubmitTraceabilityFeedbackRequest): ApiResponse<TraceabilityFeedbackResponse> {
val response = client.post {
url("$coreBaseUrl/traceability/public/feedback")
contentType(ContentType.Application.Json)
setBody(request)
accept(ContentType.Application.Json)
}
if (!response.status.isSuccess()) {
return ApiResponse(
status = false,
message = response.bodyAsText(),
data = null,
)
}
return response.body()
}
fun close() {
client.close()
}
}
+130
View File
@@ -0,0 +1,130 @@
package com.bbitcn
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
@Serializable
data class ApiResponse<T>(
val status: Boolean = true,
val message: String = "",
val data: T? = null,
)
@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 TraceBatchStepResponse(
val id: String,
val templateNodeId: String? = null,
val sort: Int,
val category: String,
val name: String,
val description: String,
val consumerVisible: Boolean,
val status: String,
val operatorName: String,
val values: JsonObject,
val completedAt: String = "",
val fields: List<TraceFieldDefinitionResponse> = emptyList(),
)
@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 TraceabilityPublicDetailResponse(
val batch: TraceBatchDetailResponse,
val companySectionTitle: String = "企业公开资料",
val publicSections: List<TraceBatchStepResponse>,
val businessSections: List<TraceBatchStepResponse>,
)
@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 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,
)
data class DisplayEntry(
val label: String,
val value: String,
val type: String = "string",
)
data class PublicSectionView(
val id: String,
val name: String,
val description: String,
val entries: List<DisplayEntry>,
)
data class TimelineSectionView(
val id: String,
val name: String,
val description: String,
val status: String,
val completedAt: String,
val entries: List<DisplayEntry>,
)
data class PageViewModel(
val code: String,
val pageUrl: String,
val batchName: String,
val productName: String,
val templateName: String,
val summary: String,
val coverImage: String,
val scanCount: Int,
val publishedAt: String,
val tagsText: String,
val publicSections: List<PublicSectionView>,
val businessSections: List<TimelineSectionView>,
)
+111
View File
@@ -0,0 +1,111 @@
package com.bbitcn
import io.ktor.server.config.ApplicationConfig
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
data class TraceabilityPublicConfig(
val coreBaseUrl: String,
val publicBaseUrl: String,
)
class TraceabilityService(
private val config: TraceabilityPublicConfig,
private val client: TraceabilityClient,
) {
suspend fun loadPage(code: String): PageViewModel? {
val detail = client.fetchPublicDetail(code, increaseScan = true) ?: return null
val batch = detail.batch
return PageViewModel(
code = batch.batchCode,
pageUrl = "${config.publicBaseUrl.trimEnd('/')}/p/${batch.batchCode}",
batchName = batch.batchName,
productName = batch.productName.ifBlank { batch.templateName },
templateName = batch.templateName,
summary = batch.summary.ifBlank { "该批次已完成关键环节留痕,可查看公开资料与业务流程。" },
coverImage = batch.coverImage,
scanCount = batch.scanCount,
publishedAt = formatDateOnly(batch.publishedAt),
tagsText = batch.tags.joinToString("").ifBlank { "暂无标签" },
publicSections = detail.publicSections.map(::toPublicSectionView),
businessSections = detail.businessSections.map(::toTimelineSectionView),
)
}
suspend fun submitFeedback(
code: String,
type: String,
contact: String,
content: String,
rating: Int,
): ApiResponse<TraceabilityFeedbackResponse> {
val normalizedType = when (type) {
"complaint", "consult", "suggestion" -> type
else -> "suggestion"
}
return client.submitFeedback(
SubmitTraceabilityFeedbackRequest(
batchCode = code,
type = normalizedType,
contact = contact.trim(),
content = content.trim(),
rating = rating.coerceIn(1, 5),
source = "public",
),
)
}
private fun toPublicSectionView(step: TraceBatchStepResponse): PublicSectionView {
return PublicSectionView(
id = step.id,
name = step.name,
description = step.description.ifBlank { "公开展示资料" },
entries = toDisplayEntries(step),
)
}
private fun toTimelineSectionView(step: TraceBatchStepResponse): TimelineSectionView {
return TimelineSectionView(
id = step.id,
name = step.name,
description = step.description.ifBlank { "流程记录" },
status = step.status,
completedAt = formatDateOnly(step.completedAt),
entries = toDisplayEntries(step),
)
}
private fun toDisplayEntries(step: TraceBatchStepResponse): List<DisplayEntry> {
return step.values.entries.map { (key, value) ->
val field = step.fields.find { it.key == key }
DisplayEntry(
label = field?.label ?: key,
value = formatJsonValue(value),
type = field?.type ?: "string",
)
}
}
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('"').ifBlank { "未填写" }
}
private fun formatDateOnly(value: String): String {
val text = value.trim()
if (text.isBlank()) {
return "未发布"
}
return text.substringBefore(" ").substringBefore("T")
}
}
fun ApplicationConfig.toTraceabilityPublicConfig(): TraceabilityPublicConfig {
return TraceabilityPublicConfig(
coreBaseUrl = property("traceability.core-base-url").getString().trimEnd('/'),
publicBaseUrl = property("traceability.public-base-url").getString().trimEnd('/'),
)
}
+10
View File
@@ -0,0 +1,10 @@
ktor:
application:
modules:
- com.bbitcn.ApplicationKt.module
deployment:
port: 8081
traceability:
core-base-url: "http://127.0.0.1:8089"
public-base-url: "http://127.0.0.1:8081"
+12
View File
@@ -0,0 +1,12 @@
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.eclipse.jetty" level="INFO"/>
<logger name="io.netty" level="INFO"/>
</configuration>
@@ -0,0 +1,376 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
color: #182235;
background:
radial-gradient(circle at top left, rgba(23, 92, 230, 0.12), transparent 32%),
linear-gradient(180deg, #f9fbff 0%, #f3f6fb 100%);
}
a {
color: #1d4ed8;
text-decoration: none;
}
.page-shell {
max-width: 1240px;
margin: 0 auto;
padding: 28px 16px 48px;
}
.hero,
.panel {
border: 1px solid rgba(228, 234, 245, 0.9);
border-radius: 28px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 16px 48px rgba(16, 24, 40, 0.08);
}
.hero {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
padding: 26px;
}
.hero--with-cover {
grid-template-columns: minmax(0, 1.2fr) 320px;
align-items: stretch;
}
.hero h1,
.panel h2,
.info-card h3,
.timeline-item__body h3 {
margin: 0;
}
.hero h1 {
margin-top: 16px;
font-size: 34px;
line-height: 1.2;
}
.hero p,
.panel__head p,
.info-card__desc,
.timeline-item__body p {
color: #667085;
line-height: 1.75;
}
.hero p {
margin: 14px 0 0;
}
.hero__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.hero__cover {
overflow: hidden;
border: 1px solid #e8eef7;
border-radius: 22px;
background: #fff;
min-height: 240px;
}
.hero__cover img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.stat-card,
.summary-card,
.kv-card,
.info-card,
.timeline-item__body,
.form-item input,
.form-item select,
.form-item textarea {
border: 1px solid #e8eef7;
border-radius: 18px;
background: #fff;
}
.stat-card,
.summary-card {
padding: 14px 16px;
}
.summary-card {
min-height: 104px;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
}
.stat-card span,
.summary-card span,
.kv-card span,
.form-item span {
display: block;
color: #7d8899;
font-size: 12px;
}
.stat-card strong,
.summary-card strong,
.kv-card strong {
display: block;
margin-top: 8px;
line-height: 1.6;
word-break: break-word;
}
.hero__aside {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.panel {
margin-top: 18px;
padding: 24px;
}
.tabs-panel {
padding-top: 18px;
}
.tabs-nav {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
padding: 8px;
border: 1px solid #e8eef7;
border-radius: 999px;
background: #f7faff;
margin-bottom: 18px;
}
.tab-btn {
min-width: 112px;
min-height: 42px;
padding: 0 18px;
border: none;
border-radius: 999px;
background: transparent;
color: #667085;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn.active {
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
color: #fff;
box-shadow: 0 10px 24px rgba(29, 78, 216, 0.2);
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.panel__head {
margin-bottom: 16px;
}
.empty-state {
border: 1px dashed #d7e1f0;
border-radius: 18px;
background: #fafcff;
color: #7d8899;
padding: 24px 18px;
}
.notice {
margin-top: 18px;
border-radius: 18px;
background: #ecfdf3;
border: 1px solid #ccebd9;
color: #0b7a4b;
padding: 14px 16px;
}
.public-grid {
display: grid;
gap: 16px;
}
.info-card {
padding: 18px;
}
.info-card__desc {
margin: 10px 0 0;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.kv-card {
padding: 12px 14px;
background: #fafcff;
}
.timeline {
display: grid;
gap: 18px;
}
.timeline-item {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
gap: 16px;
}
.timeline-item__rail {
display: flex;
flex-direction: column;
align-items: center;
}
.dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
box-shadow: 0 0 0 5px rgba(29, 78, 216, 0.12);
}
.line {
width: 2px;
flex: 1;
min-height: 70px;
margin-top: 8px;
background: linear-gradient(180deg, rgba(29, 78, 216, 0.32), rgba(29, 78, 216, 0.04));
}
.timeline-item:last-child .line {
display: none;
}
.timeline-item__body {
padding: 18px;
background: linear-gradient(180deg, #fff 0%, #fbfcff 100%);
}
.timeline-item__head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.kv-image {
display: block;
width: 100%;
max-height: 280px;
object-fit: cover;
border-radius: 14px;
margin-top: 10px;
border: 1px solid #e6edf8;
background: #fff;
}
.feedback-form {
display: grid;
gap: 14px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.form-item {
display: grid;
gap: 8px;
}
.form-item--full {
margin-top: 2px;
}
.form-item input,
.form-item select,
.form-item textarea {
width: 100%;
padding: 12px 14px;
font: inherit;
color: #182235;
}
.form-item textarea {
min-height: 140px;
resize: vertical;
}
.submit-btn,
.back-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
padding: 0 18px;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #2b63e3, #1f4fd6);
color: #fff;
font: inherit;
cursor: pointer;
}
.back-link {
margin-top: 8px;
width: fit-content;
}
.error-panel {
margin-top: 64px;
}
@media (max-width: 992px) {
.hero,
.form-grid,
.hero__aside,
.kv-grid {
grid-template-columns: 1fr;
}
.hero__stats {
grid-template-columns: 1fr;
}
.timeline-item__head {
flex-direction: column;
}
.tabs-nav {
display: grid;
grid-template-columns: 1fr;
border-radius: 20px;
}
.tab-btn {
width: 100%;
}
}
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>未找到溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<div class="page-shell">
<section class="panel error-panel">
<div class="panel__head">
<div>
<h2>未找到溯源信息</h2>
<p>${message}</p>
</div>
</div>
<a class="back-link" href="/">返回服务首页</a>
</section>
</div>
</body>
</html>
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${page.batchName} - 溯源信息</title>
<link rel="stylesheet" href="/static/traceability.css" />
</head>
<body>
<div class="page-shell">
<section class="hero<#if page.coverImage?has_content> hero--with-cover</#if>">
<div class="hero__content">
<h1>${page.batchName}</h1>
<p>${page.summary}</p>
<div class="hero__stats">
<div class="stat-card">
<span>批次编码</span>
<strong>${page.code}</strong>
</div>
<div class="stat-card">
<span>产品名称</span>
<strong>${page.productName}</strong>
</div>
<div class="stat-card">
<span>所属模板</span>
<strong>${page.templateName}</strong>
</div>
<div class="stat-card">
<span>累计访问</span>
<strong>${page.scanCount}</strong>
</div>
</div>
</div>
<#if page.coverImage?has_content>
<div class="hero__cover">
<img src="${page.coverImage}" alt="${page.productName}" />
</div>
</#if>
<div class="hero__aside">
<div class="summary-card">
<span>发布时间</span>
<strong>${page.publishedAt}</strong>
</div>
<div class="summary-card">
<span>标签</span>
<strong>${page.tagsText}</strong>
</div>
</div>
</section>
<#if feedbackMessage?has_content>
<section class="notice">${feedbackMessage}</section>
</#if>
<section class="panel tabs-panel">
<div class="tabs-nav" role="tablist" aria-label="溯源页面内容切换">
<button class="tab-btn active" data-tab-target="timeline-panel" type="button">溯源链</button>
<button class="tab-btn" data-tab-target="public-panel" type="button">公开资料</button>
<button class="tab-btn" data-tab-target="feedback-panel" type="button">反馈与投诉</button>
</div>
<div id="timeline-panel" class="tab-panel active">
<div class="panel__head">
<div>
<h2>溯源链</h2>
<p>按业务流程顺序查看本批次的处理过程与留痕信息。</p>
</div>
</div>
<#if page.businessSections?size gt 0>
<div class="timeline">
<#list page.businessSections as section>
<article class="timeline-item">
<div class="timeline-item__rail">
<span class="dot"></span>
<span class="line"></span>
</div>
<div class="timeline-item__body">
<div class="timeline-item__head">
<div>
<h3>${section.name}</h3>
<p>${section.description}</p>
</div>
</div>
<div class="kv-grid">
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
</#if>
</div>
</#list>
</div>
</div>
</article>
</#list>
</div>
<#else>
<div class="empty-state">当前批次还没有可展示的业务流程节点。</div>
</#if>
</div>
<div id="public-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>公开资料</h2>
<p>面向消费者展示的企业资料、资质证明及其他公开信息。</p>
</div>
</div>
<#if page.publicSections?size gt 0>
<div class="public-grid">
<#list page.publicSections as section>
<article class="info-card">
<div class="info-card__head">
<h3>${section.name}</h3>
</div>
<p class="info-card__desc">${section.description}</p>
<div class="kv-grid">
<#list section.entries as entry>
<div class="kv-card">
<span>${entry.label}</span>
<#if entry.type == "image" && entry.value != "未填写">
<img class="kv-image" src="${entry.value}" alt="${entry.label}" />
<#else>
<strong>${entry.value}</strong>
</#if>
</div>
</#list>
</div>
</article>
</#list>
</div>
<#else>
<div class="empty-state">当前批次还没有可展示的公开资料。</div>
</#if>
</div>
<div id="feedback-panel" class="tab-panel">
<div class="panel__head">
<div>
<h2>反馈与投诉</h2>
<p>如发现信息异常、商品质量问题,或有建议,可直接提交。</p>
</div>
</div>
<form class="feedback-form" method="post" action="/feedback">
<input type="hidden" name="batchCode" value="${page.code}" />
<div class="form-grid">
<label class="form-item">
<span>反馈类型</span>
<select name="type">
<option value="complaint">投诉</option>
<option value="suggestion">建议</option>
<option value="consult">咨询</option>
</select>
</label>
<label class="form-item">
<span>满意度</span>
<select name="rating">
<option value="5">5 分</option>
<option value="4">4 分</option>
<option value="3">3 分</option>
<option value="2">2 分</option>
<option value="1">1 分</option>
</select>
</label>
<label class="form-item">
<span>联系方式</span>
<input name="contact" placeholder="手机号 / 邮箱 / 微信" />
</label>
</div>
<label class="form-item form-item--full">
<span>反馈内容</span>
<textarea name="content" placeholder="请填写你要反馈的问题或建议" required></textarea>
</label>
<button type="submit" class="submit-btn">提交反馈</button>
</form>
</div>
</section>
</div>
<script>
const tabButtons = document.querySelectorAll('.tab-btn');
const tabPanels = document.querySelectorAll('.tab-panel');
tabButtons.forEach((button) => {
button.addEventListener('click', () => {
const targetId = button.dataset.tabTarget;
tabButtons.forEach((item) => item.classList.remove('active'));
tabPanels.forEach((panel) => panel.classList.remove('active'));
button.classList.add('active');
document.getElementById(targetId)?.classList.add('active');
});
});
if (window.location.search.includes('result=')) {
const nextUrl = window.location.pathname + window.location.hash;
window.history.replaceState({}, '', nextUrl);
}
</script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
package com.bbitcn
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.Test
import kotlin.test.assertEquals
class ApplicationTest {
@Test
fun testRoot() = testApplication {
application {
module()
}
client.get("/").apply {
assertEquals(HttpStatusCode.OK, status)
}
}
}