backend scaffold

This commit is contained in:
Guangfei.Zhao
2026-08-17 15:31:27 +08:00
commit 84fc2c0677
159 changed files with 10542 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
dependencies {
api 'org.springframework.boot:spring-boot-starter-web'
api 'org.springframework.boot:spring-boot-starter-validation'
// 幂等记录表(见 12-concurrency-and-scheduling.md)落在 platform 库里,所以这里需要 JPA
api 'org.springframework.boot:spring-boot-starter-data-jpa'
// 只要注解,不要 springdoc 的 UI —— Controller 上的 @Operation/@Tag 各 domain 都要用,
// 收口在这里比每个 domain 各写一遍好。UI 只在 bootstrap 里装(见 06-api-design.md)。
api 'io.swagger.core.v3:swagger-annotations-jakarta:2.2.30'
}
@@ -0,0 +1,21 @@
package com.continental.retailapp.platform.web
/**
* 统一响应结构,见 06-api-design.md。
* 所有 Controller 端点方法的返回类型都必须是它(ArchUnit 有规则盯着,见 10-testing.md)。
*/
data class ApiResult<T>(
val code: Int,
val message: String,
val data: T?,
val traceId: String,
) {
companion object {
fun <T> ok(data: T): ApiResult<T> = ApiResult(ErrorCode.OK, "success", data, currentTraceId())
fun ok(): ApiResult<Unit> = ApiResult(ErrorCode.OK, "success", Unit, currentTraceId())
fun error(code: Int, message: String): ApiResult<Nothing> =
ApiResult(code, message, null, currentTraceId())
}
}
@@ -0,0 +1,20 @@
package com.continental.retailapp.platform.web
/**
* 审计上下文的最小契约。
*
* 对文档的有意偏离 #103-persistence.md 的 `JpaAuditingConfig` 和 08-observability.md 的
* `AuditLogAspect` 都直接注入了 platform-security 的 `StoreContextHolder`,那会造成
* `platform-persistence -> platform-security`、`platform-observability -> platform-security`
* 两条依赖,与 01-project-structure.md「platform-* 之间尽量不互相依赖」冲突。
*
* 折中:把"当前是谁、在哪个门店"这一点点信息抽成本接口放在 platform-web(公共基座,
* `platform-security -> platform-web` 本来就是文档认可的例外),`StoreContextHolder` 实现它,
* persistence / observability 只注入 `ObjectProvider<AuditContext>`。
*
* 收敛后的规则:**platform-web 是公共基座,其他 platform-* 只能依赖它,彼此之间不再有依赖。**
*/
interface AuditContext {
val userId: Long?
val storeId: Long?
}
@@ -0,0 +1,13 @@
package com.continental.retailapp.platform.web
import org.springframework.http.HttpStatus
/**
* 业务异常基类,见 06-api-design.md。
* 子类只负责携带错误码和给用户看的文案,HTTP 状态码由 [GlobalExceptionHandler] 统一落地。
*/
open class BusinessException(
val code: Int,
override val message: String,
val httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
) : RuntimeException(message)
@@ -0,0 +1,24 @@
package com.continental.retailapp.platform.web
/**
* 业务错误码,见 06-api-design.md。
* 分段约定:1xxxx 通用、11xxx 门店/权限、3xxxx 外部系统。
* 各 domain 自己的错误码段在骨架阶段尚未分配(交付说明里已列为留白项)。
*/
object ErrorCode {
const val OK = 0
const val INVALID_PARAM = 10001
const val UNAUTHORIZED = 10401
const val FORBIDDEN = 10403
const val NOT_FOUND = 10404
const val CONFLICT = 10409
const val INTERNAL_ERROR = 10500
const val STORE_NOT_ACCESSIBLE = 11001
const val NO_STORE_PERMISSION = 11002
const val F6_UNAVAILABLE = 30001
const val F6_BUSINESS_ERROR = 30002
const val MINI_UNAVAILABLE = 31001
}
@@ -0,0 +1,44 @@
package com.continental.retailapp.platform.web
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.orm.ObjectOptimisticLockingFailureException
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
/**
* 全局异常处理,见 06-api-design.md。
* 所有异常最终都以 [ApiResult] 的结构返回,客户端只需要解析一种响应体。
*/
@RestControllerAdvice
class GlobalExceptionHandler {
private val log = LoggerFactory.getLogger(javaClass)
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiResult<Nothing>> {
val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" }
return ResponseEntity.badRequest().body(ApiResult.error(ErrorCode.INVALID_PARAM, message))
}
@ExceptionHandler(BusinessException::class)
fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> =
ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message))
@ExceptionHandler(ObjectOptimisticLockingFailureException::class)
fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity<ApiResult<Nothing>> {
log.warn("乐观锁冲突", ex)
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试"))
}
@ExceptionHandler(Exception::class)
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
// 只记异常本身,绝不打印请求体(可能含密码/令牌,见 08-observability.md 的脱敏要求)
log.error("未处理异常", ex)
return ResponseEntity.internalServerError()
.body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
}
}
@@ -0,0 +1,9 @@
package com.continental.retailapp.platform.web
import org.slf4j.MDC
/**
* 从 MDC 里取当前 traceId,见 08-observability.md。
* traceId 由 Micrometer Tracing 写进 MDC;请求头 `X-Trace-Id` 的桥接在 platform-observability 里做。
*/
fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
@@ -0,0 +1,49 @@
package com.continental.retailapp.platform.web.idempotency
import com.continental.retailapp.platform.web.BusinessException
import com.continental.retailapp.platform.web.ErrorCode
import org.springframework.dao.DataIntegrityViolationException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import tools.jackson.databind.ObjectMapper
import java.time.Clock
/**
* 幂等守卫,见 12-concurrency-and-scheduling.md。
*
* 真正的兜底是 `uk_idem_key_user` 这个唯一索引——"先查再写"之间存在竞态窗口,
* 两个并发请求可能都查不到记录然后都执行 block,所以必须捕获唯一键冲突。
*
* 与文档示例的一点补充:反序列化需要目标类型,所以签名上多了一个 `responseType`。
*/
@Service
class IdempotencyGuard(
private val recordRepository: IdempotencyRecordRepository,
private val objectMapper: ObjectMapper,
private val clock: Clock,
) {
@Transactional
fun <T : Any> execute(key: String, userId: Long, responseType: Class<T>, block: () -> T): T {
recordRepository.findByIdemKeyAndUserId(key, userId)?.let {
return objectMapper.readValue(it.response, responseType)
}
val result = block()
try {
recordRepository.saveAndFlush(
IdempotencyRecordEntity(
idemKey = key,
userId = userId,
response = objectMapper.writeValueAsString(result),
createdAt = clock.instant(),
),
)
} catch (ex: DataIntegrityViolationException) {
// 并发窗口内另一个请求先落库了:这次请求的副作用可能已经重复执行,
// 明确报冲突让客户端重试,比静默返回一个可能不一致的结果安全
throw BusinessException(ErrorCode.CONFLICT, "请求正在处理中,请稍后重试", HttpStatus.CONFLICT)
}
return result
}
}
@@ -0,0 +1,36 @@
package com.continental.retailapp.platform.web.idempotency
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Table
import java.time.Instant
/**
* 幂等记录,见 12-concurrency-and-scheduling.md。
* 表落在 platform 共用库里,DDL 见 platform-persistence 的 `db/migration/platform/V1__init.sql`。
*
* 注意它不继承 platform-persistence 的 `BaseEntity`platform-web 是公共基座,不反过来依赖
* platform-persistence(见 AuditContext 里对偏离 #1 的说明),所以这里自带 createdAt。
*/
@Entity
@Table(name = "idempotency_record", schema = "platform")
class IdempotencyRecordEntity(
@Column(name = "idem_key", nullable = false, updatable = false, length = 64)
var idemKey: String,
@Column(name = "user_id", nullable = false, updatable = false)
var userId: Long,
@Column(name = "response", nullable = false, columnDefinition = "text")
var response: String,
@Column(name = "created_at", nullable = false, updatable = false)
var createdAt: Instant = Instant.EPOCH,
) {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null
}
@@ -0,0 +1,7 @@
package com.continental.retailapp.platform.web.idempotency
import org.springframework.data.jpa.repository.JpaRepository
interface IdempotencyRecordRepository : JpaRepository<IdempotencyRecordEntity, Long> {
fun findByIdemKeyAndUserId(idemKey: String, userId: Long): IdempotencyRecordEntity?
}