backend scaffold
This commit is contained in:
@@ -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'
|
||||
}
|
||||
+21
@@ -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())
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.continental.retailapp.platform.web
|
||||
|
||||
/**
|
||||
* 审计上下文的最小契约。
|
||||
*
|
||||
* 对文档的有意偏离 #1:03-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?
|
||||
}
|
||||
+13
@@ -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)
|
||||
+24
@@ -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
|
||||
}
|
||||
+44
@@ -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, "系统繁忙,请稍后重试"))
|
||||
}
|
||||
}
|
||||
+9
@@ -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"
|
||||
+49
@@ -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
|
||||
}
|
||||
}
|
||||
+36
@@ -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
|
||||
}
|
||||
+7
@@ -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?
|
||||
}
|
||||
Reference in New Issue
Block a user