From a90298a44ad91bef69cce7a14cb7ada8b2d7dbcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 10:59:41 +0800 Subject: [PATCH 01/33] fix(contracts): align customer service and commerce schemas --- contracts/openapi/dist/eshop-v1.yaml | 64 +++++++++++++++++-- .../openapi/modules/customer-service.yaml | 11 ++-- contracts/openapi/modules/identity.yaml | 40 +++++++++++- contracts/openapi/modules/ordering.yaml | 3 +- contracts/openapi/modules/payment.yaml | 2 + frontend/src/generated/types.gen.ts | 56 +++++++++------- 6 files changed, 141 insertions(+), 35 deletions(-) diff --git a/contracts/openapi/dist/eshop-v1.yaml b/contracts/openapi/dist/eshop-v1.yaml index 543049f..571c731 100644 --- a/contracts/openapi/dist/eshop-v1.yaml +++ b/contracts/openapi/dist/eshop-v1.yaml @@ -2735,8 +2735,10 @@ paths: type: string - in: query name: userId + description: 可选顾客 ID;提供时必须为正十进制整数字符串 schema: type: string + pattern: ^[1-9][0-9]*$ - in: query name: status schema: @@ -4119,8 +4121,8 @@ paths: parameters: - $ref: '#/components/parameters/ConversationId' responses: - '204': - description: 已标记为已读 + '200': + $ref: '#/components/responses/JsonSuccess' '401': $ref: '#/components/responses/Error' '403': @@ -4229,8 +4231,8 @@ paths: parameters: - $ref: '#/components/parameters/ConversationId' responses: - '204': - description: 已标记为已读 + '200': + $ref: '#/components/responses/JsonSuccess' '401': $ref: '#/components/responses/Error' '403': @@ -4343,8 +4345,8 @@ paths: parameters: - $ref: '#/components/parameters/ConversationId' responses: - '204': - description: 已标记为已读 + '200': + $ref: '#/components/responses/JsonSuccess' '401': $ref: '#/components/responses/Error' '403': @@ -6075,6 +6077,9 @@ components: - province - city - district + - provinceCode + - cityCode + - districtCode - detail properties: receiverName: @@ -6102,6 +6107,21 @@ components: minLength: 1 maxLength: 50 pattern: \S + provinceCode: + type: string + minLength: 1 + maxLength: 10 + pattern: ^[0-9]+$ + cityCode: + type: string + minLength: 1 + maxLength: 10 + pattern: ^[0-9]+$ + districtCode: + type: string + minLength: 1 + maxLength: 10 + pattern: ^[0-9]+$ detail: type: string minLength: 1 @@ -6141,6 +6161,14 @@ components: description: 服务端会去除首尾空白;禁用用户时必须提供非空白原因 UserProfile: type: object + required: + - id + - username + - displayName + - phone + - role + - status + - createdAt properties: id: type: string @@ -6159,6 +6187,7 @@ components: enum: - customer - admin + - merchant status: type: string createdAt: @@ -6208,6 +6237,19 @@ components: $ref: '#/components/schemas/UserProfile' Address: type: object + required: + - id + - receiverName + - phone + - province + - city + - district + - provinceCode + - cityCode + - districtCode + - detail + - isDefault + - etag properties: id: type: string @@ -6221,6 +6263,12 @@ components: type: string district: type: string + provinceCode: + type: string + cityCode: + type: string + districtCode: + type: string detail: type: string isDefault: @@ -8589,6 +8637,8 @@ components: orderIds: type: array minItems: 1 + maxItems: 50 + uniqueItems: true items: type: string pattern: ^[1-9][0-9]*$ @@ -9289,6 +9339,8 @@ components: type: - string - 'null' + maxLength: 100 + description: 兼容旧客户端;服务端忽略该值并从商品数据读取可信名称 SendMessageRequest: type: object additionalProperties: false diff --git a/contracts/openapi/modules/customer-service.yaml b/contracts/openapi/modules/customer-service.yaml index 60c71cd..28b3162 100644 --- a/contracts/openapi/modules/customer-service.yaml +++ b/contracts/openapi/modules/customer-service.yaml @@ -83,7 +83,7 @@ paths: parameters: - $ref: "#/components/parameters/ConversationId" responses: - "204": { description: 已标记为已读 } + "200": { $ref: "#/components/responses/JsonSuccess" } "401": { $ref: "#/components/responses/Error" } "403": { $ref: "#/components/responses/Error" } "404": { $ref: "#/components/responses/Error" } @@ -162,7 +162,7 @@ paths: parameters: - $ref: "#/components/parameters/ConversationId" responses: - "204": { description: 已标记为已读 } + "200": { $ref: "#/components/responses/JsonSuccess" } "401": { $ref: "#/components/responses/Error" } "403": { $ref: "#/components/responses/Error" } "404": { $ref: "#/components/responses/Error" } @@ -244,7 +244,7 @@ paths: parameters: - $ref: "#/components/parameters/ConversationId" responses: - "204": { description: 已标记为已读 } + "200": { $ref: "#/components/responses/JsonSuccess" } "401": { $ref: "#/components/responses/Error" } "403": { $ref: "#/components/responses/Error" } "404": { $ref: "#/components/responses/Error" } @@ -320,7 +320,10 @@ components: shopId: { type: [string, "null"], pattern: "^[1-9][0-9]*$" } productId: { type: [string, "null"], pattern: "^[1-9][0-9]*$" } orderId: { type: [string, "null"], pattern: "^[1-9][0-9]*$" } - productName: { type: [string, "null"] } + productName: + type: [string, "null"] + maxLength: 100 + description: 兼容旧客户端;服务端忽略该值并从商品数据读取可信名称 SendMessageRequest: type: object additionalProperties: false diff --git a/contracts/openapi/modules/identity.yaml b/contracts/openapi/modules/identity.yaml index 5844a6a..8f4c3a6 100644 --- a/contracts/openapi/modules/identity.yaml +++ b/contracts/openapi/modules/identity.yaml @@ -950,7 +950,18 @@ components: CreateAddressRequest: type: object - required: [receiverName, phone, province, city, district, detail] + required: + [ + receiverName, + phone, + province, + city, + district, + provinceCode, + cityCode, + districtCode, + detail, + ] properties: receiverName: { type: string, minLength: 1, maxLength: 50, pattern: "\\S" } @@ -958,6 +969,12 @@ components: province: { type: string, minLength: 1, maxLength: 50, pattern: "\\S" } city: { type: string, minLength: 1, maxLength: 50, pattern: "\\S" } district: { type: string, minLength: 1, maxLength: 50, pattern: "\\S" } + provinceCode: + { type: string, minLength: 1, maxLength: 10, pattern: "^[0-9]+$" } + cityCode: + { type: string, minLength: 1, maxLength: 10, pattern: "^[0-9]+$" } + districtCode: + { type: string, minLength: 1, maxLength: 10, pattern: "^[0-9]+$" } detail: { type: string, minLength: 1, maxLength: 200, pattern: "\\S" } isDefault: { type: boolean, default: false } @@ -985,12 +1002,13 @@ components: # === 响应数据 === UserProfile: type: object + required: [id, username, displayName, phone, role, status, createdAt] properties: id: { type: string } username: { type: string } displayName: { type: [string, "null"] } phone: { type: [string, "null"] } - role: { type: string, enum: [customer, admin] } + role: { type: string, enum: [customer, admin, merchant] } status: { type: string } createdAt: { type: string, format: date-time } @@ -1029,6 +1047,21 @@ components: Address: type: object + required: + [ + id, + receiverName, + phone, + province, + city, + district, + provinceCode, + cityCode, + districtCode, + detail, + isDefault, + etag, + ] properties: id: { type: string } receiverName: { type: string } @@ -1036,6 +1069,9 @@ components: province: { type: string } city: { type: string } district: { type: string } + provinceCode: { type: string } + cityCode: { type: string } + districtCode: { type: string } detail: { type: string } isDefault: { type: boolean } etag: { type: string } diff --git a/contracts/openapi/modules/ordering.yaml b/contracts/openapi/modules/ordering.yaml index 920d7a4..0a2d7dd 100644 --- a/contracts/openapi/modules/ordering.yaml +++ b/contracts/openapi/modules/ordering.yaml @@ -404,7 +404,8 @@ paths: schema: { type: string } - in: query name: userId - schema: { type: string } + description: 可选顾客 ID;提供时必须为正十进制整数字符串 + schema: { type: string, pattern: "^[1-9][0-9]*$" } - in: query name: status schema: { type: string } diff --git a/contracts/openapi/modules/payment.yaml b/contracts/openapi/modules/payment.yaml index 7e00a5a..4c8e855 100644 --- a/contracts/openapi/modules/payment.yaml +++ b/contracts/openapi/modules/payment.yaml @@ -414,6 +414,8 @@ components: orderIds: type: array minItems: 1 + maxItems: 50 + uniqueItems: true items: type: string pattern: "^[1-9][0-9]*$" diff --git a/frontend/src/generated/types.gen.ts b/frontend/src/generated/types.gen.ts index 4a03afb..0356c21 100644 --- a/frontend/src/generated/types.gen.ts +++ b/frontend/src/generated/types.gen.ts @@ -123,6 +123,9 @@ export type CreateAddressRequest = { province: string; city: string; district: string; + provinceCode: string; + cityCode: string; + districtCode: string; detail: string; isDefault?: boolean; }; @@ -142,13 +145,13 @@ export type ToggleUserStatusRequest = { }; export type UserProfile = { - id?: string; - username?: string; - displayName?: string | null; - phone?: string | null; - role?: 'customer' | 'admin'; - status?: string; - createdAt?: string; + id: string; + username: string; + displayName: string | null; + phone: string | null; + role: 'customer' | 'admin' | 'merchant'; + status: string; + createdAt: string; }; export type LoginResponseEnvelope = OkEnvelope & { @@ -174,15 +177,18 @@ export type SessionEnvelope = OkEnvelope & { }; export type Address = { - id?: string; - receiverName?: string; - phone?: string; - province?: string; - city?: string; - district?: string; - detail?: string; - isDefault?: boolean; - etag?: string; + id: string; + receiverName: string; + phone: string; + province: string; + city: string; + district: string; + provinceCode: string; + cityCode: string; + districtCode: string; + detail: string; + isDefault: boolean; + etag: string; }; export type AdminUserItem = { @@ -1483,6 +1489,9 @@ export type CreateConversationRequest = { shopId?: string | null; productId?: string | null; orderId?: string | null; + /** + * 兼容旧客户端;服务端忽略该值并从商品数据读取可信名称 + */ productName?: string | null; }; @@ -5812,6 +5821,9 @@ export type OrderingAdminListOrdersData = { */ pageSize?: number; orderNo?: string; + /** + * 可选顾客 ID;提供时必须为正十进制整数字符串 + */ userId?: string; status?: string; /** @@ -8022,9 +8034,9 @@ export type CustomerServiceMarkReadError = CustomerServiceMarkReadErrors[keyof C export type CustomerServiceMarkReadResponses = { /** - * 已标记为已读 + * 操作成功 */ - 204: void; + 200: SuccessEnvelope; }; export type CustomerServiceMarkReadResponse = CustomerServiceMarkReadResponses[keyof CustomerServiceMarkReadResponses]; @@ -8217,9 +8229,9 @@ export type CustomerServiceMerchantMarkReadError = CustomerServiceMerchantMarkRe export type CustomerServiceMerchantMarkReadResponses = { /** - * 已标记为已读 + * 操作成功 */ - 204: void; + 200: SuccessEnvelope; }; export type CustomerServiceMerchantMarkReadResponse = CustomerServiceMerchantMarkReadResponses[keyof CustomerServiceMerchantMarkReadResponses]; @@ -8413,9 +8425,9 @@ export type CustomerServiceAdminMarkReadError = CustomerServiceAdminMarkReadErro export type CustomerServiceAdminMarkReadResponses = { /** - * 已标记为已读 + * 操作成功 */ - 204: void; + 200: SuccessEnvelope; }; export type CustomerServiceAdminMarkReadResponse = CustomerServiceAdminMarkReadResponses[keyof CustomerServiceAdminMarkReadResponses]; -- Gitee From 32089f395c8c4b37c1d226822834ac81afad9f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:49:06 +0800 Subject: [PATCH 02/33] fix(android): isolate customer state and stale requests --- .../group1/eshop/CustomerScreensTest.kt | 2 + .../group1/eshop/core/network/EShopApi.kt | 11 +- .../eshop/core/session/SessionManager.kt | 47 +++-- .../group1/eshop/data/remote/dto/ApiDtos.kt | 11 ++ .../data/repository/DefaultRepositories.kt | 53 +++++- .../eshop/domain/repository/Repositories.kt | 3 + .../eshop/feature/account/AccountScreen.kt | 5 +- .../group1/eshop/feature/cart/CartScreen.kt | 19 +- .../eshop/feature/cart/CartViewModel.kt | 57 ++++-- .../eshop/feature/catalog/CatalogScreen.kt | 39 ++++- .../eshop/feature/catalog/CatalogViewModel.kt | 84 ++++++++- .../feature/catalog/ProductDetailScreen.kt | 10 +- .../feature/catalog/ProductDetailViewModel.kt | 34 +++- .../eshop/feature/coupon/CouponScreen.kt | 38 +++- .../eshop/feature/coupon/CouponViewModel.kt | 107 ++++++++++-- .../eshop/feature/orders/OrdersScreen.kt | 32 +++- .../eshop/feature/orders/OrdersViewModel.kt | 63 ++++++- .../eshop/feature/session/SessionViewModel.kt | 4 +- .../group1/eshop/navigation/EShopApp.kt | 95 ++++++++-- .../eshop/core/session/SessionManagerTest.kt | 162 +++++++++++++++++- .../feature/catalog/CatalogViewModelTest.kt | 2 + .../catalog/ProductDetailViewModelTest.kt | 105 ++++++++++++ .../SessionScopedViewModelKeyTest.kt | 36 ++++ 23 files changed, 913 insertions(+), 106 deletions(-) create mode 100644 android/app/src/test/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModelTest.kt create mode 100644 android/app/src/test/java/com/class2/group1/eshop/navigation/SessionScopedViewModelKeyTest.kt diff --git a/android/app/src/androidTest/java/com/class2/group1/eshop/CustomerScreensTest.kt b/android/app/src/androidTest/java/com/class2/group1/eshop/CustomerScreensTest.kt index e5ab157..010a5f9 100644 --- a/android/app/src/androidTest/java/com/class2/group1/eshop/CustomerScreensTest.kt +++ b/android/app/src/androidTest/java/com/class2/group1/eshop/CustomerScreensTest.kt @@ -88,6 +88,7 @@ class CustomerScreensTest { onSearch = {}, onSelectCategory = {}, onRetry = {}, + onLoadMore = {}, onOpenProduct = {}, ) } @@ -128,6 +129,7 @@ class CustomerScreensTest { EShopTheme { CartScreen( signedIn = true, + customerAccess = true, state = CartUiState(cart = UiState.Content(cart)), onLoad = {}, onLogin = {}, diff --git a/android/app/src/main/java/com/class2/group1/eshop/core/network/EShopApi.kt b/android/app/src/main/java/com/class2/group1/eshop/core/network/EShopApi.kt index e39ee6e..5bf55e0 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/core/network/EShopApi.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/core/network/EShopApi.kt @@ -5,6 +5,8 @@ import com.class2.group1.eshop.data.remote.dto.ApiEnvelope import com.class2.group1.eshop.data.remote.dto.CartDto import com.class2.group1.eshop.data.remote.dto.CategoryListDto import com.class2.group1.eshop.data.remote.dto.CouponPageDto +import com.class2.group1.eshop.data.remote.dto.CouponClaimDto +import com.class2.group1.eshop.data.remote.dto.CouponClaimPageDto import com.class2.group1.eshop.data.remote.dto.LoginRequestDto import com.class2.group1.eshop.data.remote.dto.LogoutRequestDto import com.class2.group1.eshop.data.remote.dto.NativeTokenDto @@ -63,7 +65,14 @@ interface EShopApi { suspend fun claimCoupon( @Header("Authorization") authorization: String, @Path("couponId") couponId: String, - ): Response> + ): Response> + + @GET("api/v1/users/me/coupons") + suspend fun listMyCoupons( + @Header("Authorization") authorization: String, + @Query("page") page: Int = 1, + @Query("pageSize") pageSize: Int = 100, + ): Response> @GET("api/v1/cart") suspend fun getCart( diff --git a/android/app/src/main/java/com/class2/group1/eshop/core/session/SessionManager.kt b/android/app/src/main/java/com/class2/group1/eshop/core/session/SessionManager.kt index bf972e8..b9baad3 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/core/session/SessionManager.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/core/session/SessionManager.kt @@ -26,7 +26,7 @@ sealed interface SessionState { data object Restoring : SessionState data object SigningOut : SessionState data object SignedOut : SessionState - data class SignedIn(val user: User) : SessionState + data class SignedIn(val user: User, val sessionKey: Long) : SessionState data class RestoreUnavailable(val message: String) : SessionState data class SignOutFailed(val message: String) : SessionState } @@ -46,6 +46,7 @@ class SessionManager( suspend fun restore() { val generation = sessionGeneration.incrementAndGet() + accessSnapshot = null mutex.withLock { if (generation != sessionGeneration.get()) return@withLock mutableState.value = SessionState.Restoring @@ -82,6 +83,7 @@ class SessionManager( suspend fun login(username: String, password: String): User { val generation = sessionGeneration.incrementAndGet() + accessSnapshot = null return mutex.withLock { require(username.isNotBlank()) { "请输入用户名" } require(password.isNotBlank()) { "请输入密码" } @@ -117,16 +119,18 @@ class SessionManager( private suspend fun requireAccessLease(): AccessLease { val generation = sessionGeneration.get() - accessSnapshot?.takeIf { it.isUsable() && generation == sessionGeneration.get() }?.let { + accessSnapshot?.takeIf { + it.generation == generation && it.isUsable() && generation == sessionGeneration.get() + }?.let { return AccessLease(it.token, generation) } return mutex.withLock { val lockedGeneration = sessionGeneration.get() - accessSnapshot?.takeIf { it.isUsable() }?.let { + accessSnapshot?.takeIf { it.generation == lockedGeneration && it.isUsable() }?.let { return@withLock AccessLease(it.token, lockedGeneration) } val refreshToken = refreshTokenStore.read() - ?: throw ApiException(401, "COMMON_UNAUTHORIZED", "请先登录", null) + ?: invalidateMissingRefreshToken() try { AccessLease(refreshLocked(refreshToken, lockedGeneration).token, lockedGeneration) } catch (error: Throwable) { @@ -141,11 +145,13 @@ class SessionManager( private suspend fun refreshAfterUnauthorized(lease: AccessLease): AccessLease = mutex.withLock { ensureGeneration(lease.generation) - accessSnapshot?.takeIf { it.token != lease.token && it.isUsable() }?.let { + accessSnapshot?.takeIf { + it.generation == lease.generation && it.token != lease.token && it.isUsable() + }?.let { return@withLock AccessLease(it.token, lease.generation) } val refreshToken = refreshTokenStore.read() - ?: throw ApiException(401, "COMMON_UNAUTHORIZED", "请先登录", null) + ?: invalidateMissingRefreshToken() try { AccessLease(refreshLocked(refreshToken, lease.generation).token, lease.generation) } catch (error: Throwable) { @@ -167,9 +173,12 @@ class SessionManager( if (firstResponse.code() != 401) return firstResponse val refreshedLease = refreshAfterUnauthorized(firstLease) ensureGeneration(refreshedLease.generation) - return block("Bearer ${refreshedLease.token}").also { - ensureGeneration(refreshedLease.generation) + val secondResponse = block("Bearer ${refreshedLease.token}") + ensureGeneration(refreshedLease.generation) + if (secondResponse.code() == 401) { + invalidateLease(refreshedLease) } + return secondResponse } private suspend fun refreshLocked(refreshToken: String, generation: Long): AccessSnapshot { @@ -208,13 +217,14 @@ class SessionManager( } if (generation != sessionGeneration.get()) { - refreshTokenStore.clear() + // A newer restore/login/logout already owns the session transition. Preserve the + // rotated token so that the newer restore can continue instead of observing null. throw staleSession() } - return AccessSnapshot(accessToken, expiresAt, user).also { snapshot -> + return AccessSnapshot(accessToken, expiresAt, user, generation).also { snapshot -> accessSnapshot = snapshot - mutableState.value = SessionState.SignedIn(user) + mutableState.value = SessionState.SignedIn(user, generation) } } @@ -230,6 +240,20 @@ class SessionManager( mutableState.value = SessionState.SignedOut } + private suspend fun invalidateMissingRefreshToken(): Nothing { + val invalidatedGeneration = sessionGeneration.incrementAndGet() + clearLocalSession(invalidatedGeneration) + throw ApiException(401, "COMMON_UNAUTHORIZED", "请先登录", null) + } + + private suspend fun invalidateLease(lease: AccessLease) = mutex.withLock { + ensureGeneration(lease.generation) + val current = accessSnapshot + if (current?.generation != lease.generation || current.token != lease.token) return@withLock + val invalidatedGeneration = sessionGeneration.incrementAndGet() + clearLocalSession(invalidatedGeneration) + } + private fun ensureGeneration(expected: Long) { if (expected != sessionGeneration.get()) throw staleSession() } @@ -263,6 +287,7 @@ class SessionManager( val token: String, val expiresAt: Instant, val user: User, + val generation: Long, ) { fun isUsable(now: Instant = Instant.now()): Boolean = expiresAt.isAfter(now.plusSeconds(30)) diff --git a/android/app/src/main/java/com/class2/group1/eshop/data/remote/dto/ApiDtos.kt b/android/app/src/main/java/com/class2/group1/eshop/data/remote/dto/ApiDtos.kt index ce15490..bd587ff 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/data/remote/dto/ApiDtos.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/data/remote/dto/ApiDtos.kt @@ -158,6 +158,17 @@ data class CouponPageDto( val pageSize: Int?, ) +data class CouponClaimPageDto( + val items: List?, + val total: Int?, + val page: Int?, + val pageSize: Int?, +) + +data class CouponClaimDto( + val couponTemplateId: String?, +) + data class CouponDto( val id: String?, val code: String?, diff --git a/android/app/src/main/java/com/class2/group1/eshop/data/repository/DefaultRepositories.kt b/android/app/src/main/java/com/class2/group1/eshop/data/repository/DefaultRepositories.kt index 3cf45f6..c10f9fc 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/data/repository/DefaultRepositories.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/data/repository/DefaultRepositories.kt @@ -9,6 +9,7 @@ import com.class2.group1.eshop.data.remote.dto.AddCartItemRequestDto import com.class2.group1.eshop.data.remote.dto.toDomain import com.class2.group1.eshop.data.remote.dto.UpdateCartItemRequestDto import com.class2.group1.eshop.domain.model.Cart +import com.class2.group1.eshop.domain.model.Category import com.class2.group1.eshop.domain.model.CouponPage import com.class2.group1.eshop.domain.model.HomeCatalog import com.class2.group1.eshop.domain.model.OrderPage @@ -28,11 +29,7 @@ class DefaultCatalogRepository( private val resourceUrl: ManagedResourceUrl, ) : CatalogRepository { override suspend fun loadHome(): HomeCatalog = coroutineScope { - val categories = async { - api.listCategories().requirePayload(gson).data - ?.items?.map { it.toDomain() } - ?: throw IllegalStateException("服务器响应缺少分类列表") - } + val categories = async { listCategories() } val newest = async { listProducts(pageSize = 8, sortBy = "createdAt", sortOrder = "desc").items } @@ -42,6 +39,11 @@ class DefaultCatalogRepository( HomeCatalog(categories.await(), newest.await(), valuePicks.await()) } + override suspend fun listCategories(): List = + api.listCategories().requirePayload(gson).data + ?.items?.map { it.toDomain() } + ?: throw IllegalStateException("服务器响应缺少分类列表") + override suspend fun listProducts( page: Int, pageSize: Int, @@ -147,11 +149,50 @@ class DefaultCouponRepository( api.listCoupons(page, pageSize).requirePayload(gson).data?.toDomain() ?: throw IllegalStateException("服务器响应缺少优惠券列表") + override suspend fun listClaimedTemplateIds(): Set { + val templateIds = linkedSetOf() + var page = 1 + var loaded = 0 + var total: Int + do { + val data = sessionManager.authorizedRequest { authorization -> + api.listMyCoupons(authorization, page, 100) + }.requirePayload(gson).data + ?: throw IllegalStateException("服务器响应缺少已领取优惠券列表") + val returnedPageSize = data.pageSize?.takeIf { it in 1..100 } + if (data.page != page || returnedPageSize == null) { + throw IllegalStateException("服务器返回的优惠券分页信息无效") + } + val items = data.items ?: throw IllegalStateException("服务器响应缺少已领取优惠券明细") + if (items.size > returnedPageSize) { + throw IllegalStateException("服务器返回的优惠券分页大小无效") + } + total = data.total?.takeIf { it >= 0 } + ?: throw IllegalStateException("服务器响应的优惠券总数无效") + val previousUniqueCount = templateIds.size + for (item in items) { + val templateId = item.couponTemplateId + ?.takeIf { it.matches(Regex("^[1-9][0-9]*$")) } + ?: throw IllegalStateException("服务器响应的优惠券模板编号无效") + templateIds += templateId + } + loaded = templateIds.size + if (loaded > total || (loaded == previousUniqueCount && loaded < total)) { + throw IllegalStateException("服务器返回的优惠券分页未向前推进") + } + page += 1 + } while (loaded < total) + return templateIds + } + override suspend fun claim(couponId: String): String { require(couponId.matches(Regex("^[1-9][0-9]*$"))) { "优惠券编号无效" } val payload = sessionManager.authorizedRequest { authorization -> api.claimCoupon(authorization, couponId) }.requirePayload(gson) - return payload.etag.requireStrongVersionEtag() + payload.etag.requireStrongVersionEtag() + return payload.data?.couponTemplateId + ?.takeIf { it.matches(Regex("^[1-9][0-9]*$")) } + ?: throw IllegalStateException("服务器响应缺少优惠券模板编号") } } diff --git a/android/app/src/main/java/com/class2/group1/eshop/domain/repository/Repositories.kt b/android/app/src/main/java/com/class2/group1/eshop/domain/repository/Repositories.kt index 621ae9a..c13dce7 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/domain/repository/Repositories.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/domain/repository/Repositories.kt @@ -1,6 +1,7 @@ package com.class2.group1.eshop.domain.repository import com.class2.group1.eshop.domain.model.Cart +import com.class2.group1.eshop.domain.model.Category import com.class2.group1.eshop.domain.model.CouponPage import com.class2.group1.eshop.domain.model.HomeCatalog import com.class2.group1.eshop.domain.model.OrderPage @@ -9,6 +10,7 @@ import com.class2.group1.eshop.domain.model.ProductPage interface CatalogRepository { suspend fun loadHome(): HomeCatalog + suspend fun listCategories(): List suspend fun listProducts( page: Int = 1, pageSize: Int = 20, @@ -34,5 +36,6 @@ interface OrderRepository { interface CouponRepository { suspend fun listAvailable(page: Int = 1, pageSize: Int = 20): CouponPage + suspend fun listClaimedTemplateIds(): Set suspend fun claim(couponId: String): String } diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/account/AccountScreen.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/account/AccountScreen.kt index 648dc68..76c6221 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/account/AccountScreen.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/account/AccountScreen.kt @@ -49,6 +49,7 @@ import com.class2.group1.eshop.core.session.SessionState @Composable fun AccountScreen( sessionState: SessionState, + canUseCustomerFeatures: Boolean = false, onLogin: () -> Unit, onOrders: () -> Unit, onRetryRestore: () -> Unit, @@ -79,6 +80,7 @@ fun AccountScreen( ) is SessionState.SignedIn -> SignedInAccount( user = sessionState.user, + canUseCustomerFeatures = canUseCustomerFeatures, onOrders = onOrders, onLogout = onLogout, modifier = modifier, @@ -132,6 +134,7 @@ private fun GuestAccount(onLogin: () -> Unit, modifier: Modifier = Modifier) { @Composable private fun SignedInAccount( user: com.class2.group1.eshop.domain.model.User, + canUseCustomerFeatures: Boolean, onOrders: () -> Unit, onLogout: () -> Unit, modifier: Modifier = Modifier, @@ -178,7 +181,7 @@ private fun SignedInAccount( } } } - item { + if (canUseCustomerFeatures) item { Card( shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartScreen.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartScreen.kt index 5092a6d..498d8ae 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartScreen.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartScreen.kt @@ -58,6 +58,7 @@ import com.class2.group1.eshop.domain.model.CartItem @Composable fun CartScreen( signedIn: Boolean, + customerAccess: Boolean, sessionRestoring: Boolean = false, sessionError: String? = null, state: CartUiState, @@ -72,9 +73,6 @@ fun CartScreen( modifier: Modifier = Modifier, ) { val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(signedIn) { - if (signedIn) onLoad() - } LaunchedEffect(state.notice) { state.notice?.let { snackbarHostState.showSnackbar(it) @@ -95,6 +93,10 @@ fun CartScreen( LoginGate(onLogin, modifier) return } + if (!customerAccess) { + CustomerOnlyGate(modifier) + return + } Scaffold( modifier = modifier, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -399,3 +401,14 @@ private fun LoginGate(onLogin: () -> Unit, modifier: Modifier = Modifier) { } } } + +@Composable +private fun CustomerOnlyGate(modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + "购物车仅面向顾客账户", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + ) + } +} diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartViewModel.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartViewModel.kt index 7107059..85e3c00 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartViewModel.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/cart/CartViewModel.kt @@ -9,6 +9,7 @@ import com.class2.group1.eshop.core.ui.toUiError import com.class2.group1.eshop.core.util.runSuspendCatching import com.class2.group1.eshop.domain.model.Cart import com.class2.group1.eshop.domain.repository.CartRepository +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -23,16 +24,27 @@ data class CartUiState( class CartViewModel(private val cartRepository: CartRepository) : ViewModel() { private val mutableState = MutableStateFlow(CartUiState()) val state: StateFlow = mutableState.asStateFlow() + private var loadJob: Job? = null + private var loadGeneration = 0 + private var mutationGeneration = 0 - fun load() { - viewModelScope.launch { - mutableState.value = mutableState.value.copy(cart = UiState.Loading, notice = null) + fun load(force: Boolean = false) { + if (mutableState.value.mutatingItemId != null && !force) return + val generation = ++loadGeneration + if (force) { + mutationGeneration += 1 + mutableState.value = mutableState.value.copy(mutatingItemId = null) + } + loadJob?.cancel() + mutableState.value = mutableState.value.copy(cart = UiState.Loading, notice = null) + loadJob = viewModelScope.launch { + val result = runSuspendCatching { cartRepository.getCart() } + if (generation != loadGeneration) return@launch mutableState.value = mutableState.value.copy( - cart = runSuspendCatching { cartRepository.getCart() } - .fold( - onSuccess = { UiState.Content(it) }, - onFailure = { it.toUiError() }, - ), + cart = result.fold( + onSuccess = { UiState.Content(it) }, + onFailure = { it.toUiError() }, + ), ) } } @@ -52,9 +64,13 @@ class CartViewModel(private val cartRepository: CartRepository) : ViewModel() { } private fun mutate(itemId: String, operation: suspend () -> Cart) { + val generation = ++mutationGeneration + loadGeneration += 1 + loadJob?.cancel() + mutableState.value = mutableState.value.copy(mutatingItemId = itemId, notice = null) viewModelScope.launch { - mutableState.value = mutableState.value.copy(mutatingItemId = itemId, notice = null) val result = runSuspendCatching { operation() } + if (generation != mutationGeneration) return@launch result.fold( onSuccess = { mutableState.value = mutableState.value.copy( @@ -65,13 +81,22 @@ class CartViewModel(private val cartRepository: CartRepository) : ViewModel() { onFailure = { error -> if ((error as? ApiException)?.httpStatus == 412) { val refreshed = runSuspendCatching { cartRepository.getCart() } - mutableState.value = mutableState.value.copy( - cart = refreshed.fold( - onSuccess = { UiState.Content(it) }, - onFailure = { it.toUiError() }, - ), - mutatingItemId = null, - notice = "购物车已刷新,请确认最新数量后再继续", + if (generation != mutationGeneration) return@launch + mutableState.value = refreshed.fold( + onSuccess = { + mutableState.value.copy( + cart = UiState.Content(it), + mutatingItemId = null, + notice = "购物车已刷新,请确认最新数量后再继续", + ) + }, + onFailure = { + mutableState.value.copy( + cart = it.toUiError(), + mutatingItemId = null, + notice = "购物车刷新失败:${userFacingMessage(it)}", + ) + }, ) } else { mutableState.value = mutableState.value.copy( diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogScreen.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogScreen.kt index 4130200..1bbd00a 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogScreen.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogScreen.kt @@ -11,10 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -29,11 +31,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -57,6 +61,7 @@ fun CatalogScreen( onSearch: (String) -> Unit, onSelectCategory: (String?) -> Unit, onRetry: () -> Unit, + onLoadMore: () -> Unit, onOpenProduct: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -67,6 +72,7 @@ fun CatalogScreen( content = state.value, onSearch = onSearch, onSelectCategory = onSelectCategory, + onLoadMore = onLoadMore, onOpenProduct = onOpenProduct, modifier = modifier, ) @@ -79,15 +85,18 @@ private fun CatalogContent( content: CatalogContent, onSearch: (String) -> Unit, onSelectCategory: (String?) -> Unit, + onLoadMore: () -> Unit, onOpenProduct: (String) -> Unit, modifier: Modifier = Modifier, ) { - var query by remember { mutableStateOf(content.query) } + var query by rememberSaveable(content.query) { mutableStateOf(content.query) } val focusManager = LocalFocusManager.current + val gridState = rememberLazyGridState() LaunchedEffect(content.query) { query = content.query } LazyVerticalGrid( columns = GridCells.Adaptive(160.dp), + state = gridState, modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(start = 14.dp, end = 14.dp, top = 14.dp, bottom = 30.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -158,6 +167,32 @@ private fun CatalogContent( modifier = Modifier.fillMaxWidth(), ) } + if ( + content.products.items.isNotEmpty() && + (content.products.items.size < content.products.total || content.loadMoreError != null) + ) { + item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + content.loadMoreError?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + Button(onClick = onLoadMore, enabled = !content.loadingMore) { + if (content.loadingMore) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + } + Text(if (content.loadingMore) "加载中…" else "加载更多") + } + } + } + } } } diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogViewModel.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogViewModel.kt index fbade88..ce653f5 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogViewModel.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/CatalogViewModel.kt @@ -1,9 +1,11 @@ package com.class2.group1.eshop.feature.catalog import androidx.lifecycle.ViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.class2.group1.eshop.core.ui.UiState import com.class2.group1.eshop.core.ui.toUiError +import com.class2.group1.eshop.core.network.userFacingMessage import com.class2.group1.eshop.core.util.runSuspendCatching import com.class2.group1.eshop.domain.model.Category import com.class2.group1.eshop.domain.model.ProductPage @@ -21,14 +23,21 @@ data class CatalogContent( val products: ProductPage, val query: String = "", val selectedCategoryId: String? = null, + val loadingMore: Boolean = false, + val loadMoreError: String? = null, ) -class CatalogViewModel(private val catalogRepository: CatalogRepository) : ViewModel() { +class CatalogViewModel( + private val catalogRepository: CatalogRepository, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), +) : ViewModel() { private val mutableState = MutableStateFlow>(UiState.Loading) val state: StateFlow> = mutableState.asStateFlow() private var loadJob: Job? = null - private var lastQuery = "" - private var lastCategoryId: String? = null + private var loadMoreJob: Job? = null + private var loadGeneration = 0 + private var lastQuery = savedStateHandle[QUERY_KEY] ?: "" + private var lastCategoryId: String? = savedStateHandle[CATEGORY_KEY] init { load() @@ -39,20 +48,25 @@ class CatalogViewModel(private val catalogRepository: CatalogRepository) : ViewM } fun selectCategory(categoryId: String?) { + if (categoryId == lastCategoryId && mutableState.value is UiState.Content) return load(lastQuery, categoryId) } fun load(query: String = lastQuery, categoryId: String? = lastCategoryId) { lastQuery = query lastCategoryId = categoryId + savedStateHandle[QUERY_KEY] = query + savedStateHandle[CATEGORY_KEY] = categoryId + val generation = ++loadGeneration loadJob?.cancel() + loadMoreJob?.cancel() + val previous = (mutableState.value as? UiState.Content)?.value + mutableState.value = UiState.Loading loadJob = viewModelScope.launch { - val previous = (mutableState.value as? UiState.Content)?.value - mutableState.value = UiState.Loading - mutableState.value = runSuspendCatching { + val result = runSuspendCatching { coroutineScope { val categories = async { - previous?.categories ?: catalogRepository.loadHome().categories + previous?.categories ?: catalogRepository.listCategories() } val products = async { catalogRepository.listProducts( @@ -65,10 +79,64 @@ class CatalogViewModel(private val catalogRepository: CatalogRepository) : ViewM } CatalogContent(categories.await(), products.await(), query, categoryId) } - }.fold( + } + if (generation != loadGeneration) return@launch + mutableState.value = result.fold( onSuccess = { UiState.Content(it) }, onFailure = { it.toUiError() }, ) } } + + fun loadMore() { + val current = (mutableState.value as? UiState.Content)?.value ?: return + if ( + current.loadingMore || + current.products.items.size >= current.products.total || + loadMoreJob?.isActive == true + ) return + val generation = loadGeneration + mutableState.value = UiState.Content(current.copy(loadingMore = true, loadMoreError = null)) + loadMoreJob = viewModelScope.launch { + val result = runSuspendCatching { + catalogRepository.listProducts( + page = current.products.page + 1, + pageSize = current.products.pageSize, + categoryId = current.selectedCategoryId, + keyword = current.query.takeIf { it.isNotBlank() }, + sortBy = "createdAt", + sortOrder = "desc", + ) + } + if (generation != loadGeneration) return@launch + val latest = (mutableState.value as? UiState.Content)?.value ?: return@launch + mutableState.value = UiState.Content( + result.fold( + onSuccess = { page -> + val merged = (latest.products.items + page.items).distinctBy { it.id } + if (page.page <= latest.products.page || merged.size == latest.products.items.size) { + latest.copy( + loadingMore = false, + loadMoreError = "商品分页未向前推进,请重试", + ) + } else { + latest.copy( + products = page.copy(items = merged), + loadingMore = false, + loadMoreError = null, + ) + } + }, + onFailure = { + latest.copy(loadingMore = false, loadMoreError = userFacingMessage(it)) + }, + ), + ) + } + } + + private companion object { + const val QUERY_KEY = "catalog.query" + const val CATEGORY_KEY = "catalog.categoryId" + } } diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailScreen.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailScreen.kt index 063eb13..91e3ab7 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailScreen.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailScreen.kt @@ -61,6 +61,7 @@ import com.google.gson.JsonParser fun ProductDetailScreen( state: ProductDetailUiState, signedIn: Boolean, + customerAccess: Boolean, sessionRestoring: Boolean = false, onBack: () -> Unit, onRetry: () -> Unit, @@ -98,6 +99,7 @@ fun ProductDetailScreen( adding = state.adding, inStock = state.product.value.inStock, signedIn = signedIn, + customerAccess = customerAccess, sessionRestoring = sessionRestoring, onChangeQuantity = onChangeQuantity, onAddToCart = onAddToCart, @@ -239,6 +241,7 @@ private fun PurchaseBar( adding: Boolean, inStock: Boolean, signedIn: Boolean, + customerAccess: Boolean, sessionRestoring: Boolean, onChangeQuantity: (Int) -> Unit, onAddToCart: () -> Unit, @@ -273,7 +276,11 @@ private fun PurchaseBar( } Button( onClick = onAddToCart, - enabled = inStock && !adding && !sessionRestoring, + enabled = + inStock && + !adding && + !sessionRestoring && + (!signedIn || customerAccess), modifier = Modifier.weight(1f).height(50.dp), shape = RoundedCornerShape(13.dp), colors = ButtonDefaults.buttonColors( @@ -295,6 +302,7 @@ private fun PurchaseBar( !inStock -> "暂时缺货" sessionRestoring -> "正在恢复登录状态…" !signedIn -> "登录后加入购物车" + !customerAccess -> "仅顾客可加入购物车" else -> "加入购物车" }, fontWeight = FontWeight.Bold, diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModel.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModel.kt index 7632814..3a34f5b 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModel.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModel.kt @@ -1,6 +1,7 @@ package com.class2.group1.eshop.feature.catalog import androidx.lifecycle.ViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.class2.group1.eshop.core.network.userFacingMessage import com.class2.group1.eshop.core.ui.UiState @@ -26,40 +27,52 @@ class ProductDetailViewModel( private val productId: String, private val catalogRepository: CatalogRepository, private val cartRepository: CartRepository, + private val savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { - private val mutableState = MutableStateFlow(ProductDetailUiState()) + private val mutableState = MutableStateFlow( + ProductDetailUiState(quantity = (savedStateHandle[QUANTITY_KEY] ?: 1).coerceIn(1, 999)), + ) val state: StateFlow = mutableState.asStateFlow() private var loadJob: Job? = null + private var loadGeneration = 0 + private var addGeneration = 0 init { + savedStateHandle[QUANTITY_KEY] = mutableState.value.quantity load() } fun load() { + val generation = ++loadGeneration loadJob?.cancel() + mutableState.value = mutableState.value.copy(product = UiState.Loading, notice = null) loadJob = viewModelScope.launch { - mutableState.value = mutableState.value.copy(product = UiState.Loading, notice = null) val next = runSuspendCatching { catalogRepository.getProduct(productId) } .fold( onSuccess = { UiState.Content(it) }, onFailure = { it.toUiError() }, ) + if (generation != loadGeneration) return@launch mutableState.value = mutableState.value.copy(product = next) } } fun changeQuantity(delta: Int) { - mutableState.value = mutableState.value.copy( - quantity = (mutableState.value.quantity + delta).coerceIn(1, 999), - ) + val quantity = (mutableState.value.quantity + delta).coerceIn(1, 999) + savedStateHandle[QUANTITY_KEY] = quantity + mutableState.value = mutableState.value.copy(quantity = quantity) } fun addToCart(onSuccess: () -> Unit = {}) { - if (mutableState.value.adding) return + val current = mutableState.value + if (current.adding) return + val quantity = current.quantity + val generation = ++addGeneration + mutableState.value = current.copy(adding = true, notice = null) viewModelScope.launch { - mutableState.value = mutableState.value.copy(adding = true, notice = null) - runSuspendCatching { cartRepository.addItem(productId, mutableState.value.quantity) } + runSuspendCatching { cartRepository.addItem(productId, quantity) } .onSuccess { + if (generation != addGeneration) return@onSuccess mutableState.value = mutableState.value.copy( adding = false, notice = "已加入购物车", @@ -67,6 +80,7 @@ class ProductDetailViewModel( onSuccess() } .onFailure { + if (generation != addGeneration) return@onFailure mutableState.value = mutableState.value.copy( adding = false, notice = userFacingMessage(it), @@ -78,4 +92,8 @@ class ProductDetailViewModel( fun consumeNotice() { mutableState.value = mutableState.value.copy(notice = null) } + + private companion object { + const val QUANTITY_KEY = "product.quantity" + } } diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponScreen.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponScreen.kt index ce1154e..5d74ac4 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponScreen.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponScreen.kt @@ -52,8 +52,10 @@ import com.class2.group1.eshop.domain.model.Coupon fun CouponScreen( state: CouponUiState, signedIn: Boolean, + customerAccess: Boolean, sessionRestoring: Boolean = false, onRetry: () -> Unit, + onLoadMore: () -> Unit, onClaim: (String) -> Unit, onLogin: () -> Unit, onConsumeNotice: () -> Unit, @@ -104,15 +106,39 @@ fun CouponScreen( CouponCard( coupon = coupon, claiming = state.claimingId == coupon.id, - interactionLocked = state.claimingId != null || sessionRestoring, + claimed = coupon.id in state.claimedTemplateIds, + interactionLocked = + state.claimingId != null || + sessionRestoring || + (signedIn && !customerAccess), signedIn = signedIn, + customerAccess = customerAccess, sessionRestoring = sessionRestoring, onClaim = { - if (signedIn) onClaim(coupon.id) else onLogin() + when { + !signedIn -> onLogin() + customerAccess -> onClaim(coupon.id) + } }, ) } } + if (coupons.value.items.size < coupons.value.total || state.loadMoreError != null) { + item { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.loadMoreError?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + Button(onClick = onLoadMore, enabled = !state.loadingMore) { + Text(if (state.loadingMore) "加载中…" else "加载更多") + } + } + } + } } } } @@ -152,8 +178,10 @@ private fun CouponHero() { private fun CouponCard( coupon: Coupon, claiming: Boolean, + claimed: Boolean, interactionLocked: Boolean, signedIn: Boolean, + customerAccess: Boolean, sessionRestoring: Boolean, onClaim: () -> Unit, ) { @@ -206,7 +234,7 @@ private fun CouponCard( } Button( onClick = onClaim, - enabled = !interactionLocked, + enabled = !interactionLocked && !claimed, shape = RoundedCornerShape(12.dp), contentPadding = PaddingValues(horizontal = 14.dp, vertical = 11.dp), ) { @@ -222,7 +250,9 @@ private fun CouponCard( Text( when { sessionRestoring -> "正在恢复…" - signedIn -> "领取" + claimed -> "已领取" + signedIn && !customerAccess -> "仅顾客可领" + customerAccess -> "领取" else -> "登录领取" }, fontWeight = FontWeight.Bold, diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponViewModel.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponViewModel.kt index 8e5e1fc..20ea7cf 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponViewModel.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/coupon/CouponViewModel.kt @@ -16,7 +16,10 @@ import kotlinx.coroutines.launch data class CouponUiState( val coupons: UiState = UiState.Loading, + val claimedTemplateIds: Set = emptySet(), val claimingId: String? = null, + val loadingMore: Boolean = false, + val loadMoreError: String? = null, val notice: String? = null, ) @@ -24,38 +27,90 @@ class CouponViewModel(private val repository: CouponRepository) : ViewModel() { private val mutableState = MutableStateFlow(CouponUiState()) val state: StateFlow = mutableState.asStateFlow() private var loadJob: Job? = null + private var loadMoreJob: Job? = null + private var claimedJob: Job? = null + private var claimJob: Job? = null + private var customerSessionId: String? = null + private var loadGeneration = 0 init { load() } fun load() { + val generation = ++loadGeneration loadJob?.cancel() + loadMoreJob?.cancel() + mutableState.value = mutableState.value.copy( + coupons = UiState.Loading, + loadingMore = false, + loadMoreError = null, + notice = null, + ) loadJob = viewModelScope.launch { - mutableState.value = mutableState.value.copy(coupons = UiState.Loading, notice = null) + val result = runSuspendCatching { repository.listAvailable() } + if (generation != loadGeneration) return@launch mutableState.value = mutableState.value.copy( - coupons = runSuspendCatching { repository.listAvailable() } - .fold( - onSuccess = { UiState.Content(it) }, - onFailure = { it.toUiError() }, - ), + coupons = result.fold( + onSuccess = { UiState.Content(it) }, + onFailure = { it.toUiError() }, + ), + ) + } + } + + fun loadMore() { + val page = (mutableState.value.coupons as? UiState.Content)?.value ?: return + if (page.items.size >= page.total || loadMoreJob?.isActive == true) return + val generation = loadGeneration + mutableState.value = mutableState.value.copy(loadingMore = true, loadMoreError = null) + loadMoreJob = viewModelScope.launch { + val result = runSuspendCatching { + repository.listAvailable(page = page.page + 1, pageSize = page.pageSize) + } + if (generation != loadGeneration) return@launch + val latest = (mutableState.value.coupons as? UiState.Content)?.value ?: return@launch + mutableState.value = result.fold( + onSuccess = { next -> + val merged = (latest.items + next.items).distinctBy { it.id } + if (next.page <= latest.page || merged.size == latest.items.size) { + mutableState.value.copy( + loadingMore = false, + loadMoreError = "优惠券分页未向前推进,请重试", + ) + } else { + mutableState.value.copy( + coupons = UiState.Content(next.copy(items = merged)), + loadingMore = false, + loadMoreError = null, + ) + } + }, + onFailure = { + mutableState.value.copy( + loadingMore = false, + loadMoreError = userFacingMessage(it), + ) + }, ) } } fun claim(couponId: String) { - if (mutableState.value.claimingId != null) return - viewModelScope.launch { + if ( + customerSessionId == null || + couponId in mutableState.value.claimedTemplateIds || + mutableState.value.claimingId != null + ) return + val requestedSessionId = customerSessionId ?: return + claimJob = viewModelScope.launch { mutableState.value = mutableState.value.copy(claimingId = couponId, notice = null) val result = runSuspendCatching { repository.claim(couponId) } + if (customerSessionId != requestedSessionId) return@launch result.fold( - onSuccess = { - val refreshed = runSuspendCatching { repository.listAvailable() } + onSuccess = { claimedTemplateId -> mutableState.value = mutableState.value.copy( - coupons = refreshed.fold( - onSuccess = { UiState.Content(it) }, - onFailure = { it.toUiError() }, - ), + claimedTemplateIds = mutableState.value.claimedTemplateIds + claimedTemplateId, claimingId = null, notice = "优惠券已放入账户", ) @@ -70,6 +125,30 @@ class CouponViewModel(private val repository: CouponRepository) : ViewModel() { } } + fun setCustomerSession(userId: String?, forceRefresh: Boolean = false) { + if (customerSessionId == userId && !forceRefresh) return + customerSessionId = userId + claimedJob?.cancel() + claimJob?.cancel() + mutableState.value = mutableState.value.copy( + claimedTemplateIds = emptySet(), + claimingId = null, + ) + if (userId == null) return + claimedJob = viewModelScope.launch { + val result = runSuspendCatching { repository.listClaimedTemplateIds() } + if (customerSessionId != userId) return@launch + mutableState.value = result.fold( + onSuccess = { + mutableState.value.copy( + claimedTemplateIds = mutableState.value.claimedTemplateIds + it, + ) + }, + onFailure = { mutableState.value.copy(notice = userFacingMessage(it)) }, + ) + } + } + fun consumeNotice() { mutableState.value = mutableState.value.copy(notice = null) } diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersScreen.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersScreen.kt index c394841..caecbe5 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersScreen.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersScreen.kt @@ -27,10 +27,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.Button import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -45,12 +45,12 @@ import com.class2.group1.eshop.domain.model.OrderSummary @OptIn(ExperimentalMaterial3Api::class) @Composable fun OrdersScreen( - state: UiState, + state: OrdersUiState, onLoad: () -> Unit, + onLoadMore: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { - LaunchedEffect(Unit) { onLoad() } Column(modifier.fillMaxSize()) { TopAppBar( title = { Text("我的订单") }, @@ -61,13 +61,13 @@ fun OrdersScreen( }, colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), ) - when (state) { + when (val orders = state.orders) { UiState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - is UiState.Error -> ErrorState(state.message, onLoad, Modifier.fillMaxSize(), state.traceId) + is UiState.Error -> ErrorState(orders.message, onLoad, Modifier.fillMaxSize(), orders.traceId) is UiState.Content -> { - if (state.value.items.isEmpty()) { + if (orders.value.items.isEmpty()) { EmptyOrders() } else { LazyColumn( @@ -78,10 +78,26 @@ fun OrdersScreen( SectionHeading( eyebrow = "ORDER HISTORY", title = "每一单,都有迹可循", - supporting = "共 ${state.value.total} 笔订单", + supporting = "共 ${orders.value.total} 笔订单", ) } - items(state.value.items, key = { it.id }) { OrderCard(it) } + items(orders.value.items, key = { it.id }) { OrderCard(it) } + if (orders.value.items.size < orders.value.total || state.loadMoreError != null) { + item { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + state.loadMoreError?.let { + Text(it, color = MaterialTheme.colorScheme.error) + } + Button(onClick = onLoadMore, enabled = !state.loadingMore) { + Text(if (state.loadingMore) "加载中…" else "加载更多") + } + } + } + } } } } diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersViewModel.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersViewModel.kt index a3a4f48..a25deee 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersViewModel.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/orders/OrdersViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.class2.group1.eshop.core.ui.UiState import com.class2.group1.eshop.core.ui.toUiError +import com.class2.group1.eshop.core.network.userFacingMessage import com.class2.group1.eshop.core.util.runSuspendCatching import com.class2.group1.eshop.domain.model.OrderPage import com.class2.group1.eshop.domain.repository.OrderRepository @@ -13,20 +14,70 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +data class OrdersUiState( + val orders: UiState = UiState.Loading, + val loadingMore: Boolean = false, + val loadMoreError: String? = null, +) + class OrdersViewModel(private val orderRepository: OrderRepository) : ViewModel() { - private val mutableState = MutableStateFlow>(UiState.Loading) - val state: StateFlow> = mutableState.asStateFlow() + private val mutableState = MutableStateFlow(OrdersUiState()) + val state: StateFlow = mutableState.asStateFlow() private var loadJob: Job? = null + private var loadMoreJob: Job? = null + private var generation = 0 fun load() { + val requestGeneration = ++generation loadJob?.cancel() + loadMoreJob?.cancel() + mutableState.value = OrdersUiState(orders = UiState.Loading) loadJob = viewModelScope.launch { - mutableState.value = UiState.Loading - mutableState.value = runSuspendCatching { orderRepository.listOrders() } - .fold( + val result = runSuspendCatching { orderRepository.listOrders() } + if (requestGeneration != generation) return@launch + mutableState.value = mutableState.value.copy( + orders = result.fold( onSuccess = { UiState.Content(it) }, onFailure = { it.toUiError() }, - ) + ), + ) + } + } + + fun loadMore() { + val page = (mutableState.value.orders as? UiState.Content)?.value ?: return + if (page.items.size >= page.total || loadMoreJob?.isActive == true) return + val requestGeneration = generation + mutableState.value = mutableState.value.copy(loadingMore = true, loadMoreError = null) + loadMoreJob = viewModelScope.launch { + val result = runSuspendCatching { + orderRepository.listOrders(page = page.page + 1, pageSize = page.pageSize) + } + if (requestGeneration != generation) return@launch + val latest = (mutableState.value.orders as? UiState.Content)?.value ?: return@launch + mutableState.value = result.fold( + onSuccess = { next -> + val merged = (latest.items + next.items).distinctBy { it.id } + if (next.page <= latest.page || merged.size == latest.items.size) { + mutableState.value.copy( + loadingMore = false, + loadMoreError = "订单分页未向前推进,请重试", + ) + } else { + mutableState.value.copy( + orders = UiState.Content(next.copy(items = merged)), + loadingMore = false, + loadMoreError = null, + ) + } + }, + onFailure = { + mutableState.value.copy( + loadingMore = false, + loadMoreError = userFacingMessage(it), + ) + }, + ) } } } diff --git a/android/app/src/main/java/com/class2/group1/eshop/feature/session/SessionViewModel.kt b/android/app/src/main/java/com/class2/group1/eshop/feature/session/SessionViewModel.kt index e007669..8f74da5 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/feature/session/SessionViewModel.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/feature/session/SessionViewModel.kt @@ -12,11 +12,11 @@ class SessionViewModel(private val sessionManager: SessionManager) : ViewModel() val state: StateFlow = sessionManager.state init { - viewModelScope.launch { sessionManager.restore() } + viewModelScope.launch { runSuspendCatching { sessionManager.restore() } } } fun retryRestore() { - viewModelScope.launch { sessionManager.restore() } + viewModelScope.launch { runSuspendCatching { sessionManager.restore() } } } fun logout(onComplete: () -> Unit = {}) { diff --git a/android/app/src/main/java/com/class2/group1/eshop/navigation/EShopApp.kt b/android/app/src/main/java/com/class2/group1/eshop/navigation/EShopApp.kt index fe26e5d..f0776d4 100644 --- a/android/app/src/main/java/com/class2/group1/eshop/navigation/EShopApp.kt +++ b/android/app/src/main/java/com/class2/group1/eshop/navigation/EShopApp.kt @@ -20,13 +20,17 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.vector.ImageVector import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.createSavedStateHandle import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import androidx.navigation.NavDestination.Companion.hierarchy @@ -86,22 +90,37 @@ private val topDestinations = listOf( TopDestination(Routes.Account, "我的", Icons.Rounded.Person), ) +internal fun customerSessionScopeKey(sessionState: SessionState): String { + val signedIn = sessionState as? SessionState.SignedIn ?: return "anonymous" + if (signedIn.user.role != "customer") return "anonymous" + return "customer-${signedIn.user.id}-${signedIn.sessionKey}" +} + @Composable fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { val navController = rememberNavController() val backStackEntry by navController.currentBackStackEntryAsState() val currentDestination = backStackEntry?.destination val currentRoute = currentDestination?.route - val showBottomBar = topDestinations.any { destination -> - currentRoute == destination.route || - (destination.route == Routes.Catalog && currentRoute?.startsWith("catalog") == true) - } - val sessionViewModel: SessionViewModel = viewModel( factory = viewModelFactory { initializer { SessionViewModel(container.sessionManager) } }, ) val sessionState by sessionViewModel.state.collectAsStateWithLifecycle() - val signedIn = sessionState is SessionState.SignedIn + val signedInSession = sessionState as? SessionState.SignedIn + val signedInUser = signedInSession?.user + val signedIn = signedInUser != null + val customerUserId = signedInUser?.takeIf { it.role == "customer" }?.id + val customerAccess = customerUserId != null + val customerSessionScope = customerSessionScopeKey(sessionState) + val visibleTopDestinations = if (customerAccess) { + topDestinations + } else { + topDestinations.filterNot { it.route == Routes.Cart } + } + val showBottomBar = visibleTopDestinations.any { destination -> + currentRoute == destination.route || + (destination.route == Routes.Catalog && currentRoute?.startsWith("catalog") == true) + } val sessionRestoring = sessionState is SessionState.Restoring || sessionState is SessionState.SigningOut val sessionError = when (val current = sessionState) { @@ -115,7 +134,7 @@ fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { bottomBar = { if (showBottomBar) { NavigationBar(containerColor = MaterialTheme.colorScheme.surface) { - topDestinations.forEach { destination -> + visibleTopDestinations.forEach { destination -> val selected = currentDestination?.hierarchy?.any { it.route == destination.route || (destination.route == Routes.Catalog && it.route?.startsWith("catalog") == true) @@ -170,48 +189,75 @@ fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { ), ) { entry -> val vm: CatalogViewModel = viewModel( - factory = viewModelFactory { initializer { CatalogViewModel(container.catalogRepository) } }, + factory = viewModelFactory { + initializer { + CatalogViewModel(container.catalogRepository, createSavedStateHandle()) + } + }, ) val state by vm.state.collectAsStateWithLifecycle() val categoryId = entry.arguments?.getString("categoryId") LaunchedEffect(categoryId) { - if (categoryId != null) vm.selectCategory(categoryId) + vm.selectCategory(categoryId) } CatalogScreen( state = state, onSearch = vm::search, onSelectCategory = vm::selectCategory, onRetry = vm::load, + onLoadMore = vm::loadMore, onOpenProduct = { navController.navigate(Routes.product(it)) }, ) } composable(Routes.Coupons) { val vm: CouponViewModel = viewModel( + key = "coupons-$customerSessionScope", factory = viewModelFactory { initializer { CouponViewModel(container.couponRepository) } }, ) val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(customerUserId, sessionState) { + vm.setCustomerSession(customerUserId, forceRefresh = customerAccess) + } CouponScreen( state = state, signedIn = signedIn, + customerAccess = customerAccess, sessionRestoring = sessionRestoring, onRetry = vm::load, + onLoadMore = vm::loadMore, onClaim = vm::claim, onLogin = { navController.navigate(Routes.Login) }, onConsumeNotice = vm::consumeNotice, ) } - composable(Routes.Cart) { + composable(Routes.Cart) { entry -> val vm: CartViewModel = viewModel( + key = "cart-$customerSessionScope", factory = viewModelFactory { initializer { CartViewModel(container.cartRepository) } }, ) val state by vm.state.collectAsStateWithLifecycle() + DisposableEffect(entry.lifecycle, customerUserId) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && customerAccess) vm.load() + } + entry.lifecycle.addObserver(observer) + if (customerAccess && entry.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + vm.load(force = true) + } + onDispose { entry.lifecycle.removeObserver(observer) } + } CartScreen( signedIn = signedIn, + customerAccess = customerAccess, sessionRestoring = sessionRestoring, sessionError = sessionError, state = state, onLoad = vm::load, - onRetrySession = sessionViewModel::retryRestore, + onRetrySession = if (sessionState is SessionState.SignOutFailed) { + { sessionViewModel.logout() } + } else { + { sessionViewModel.retryRestore() } + }, onLogin = { navController.navigate(Routes.Login) }, onBrowse = { navController.navigate(Routes.Catalog) }, onOpenProduct = { navController.navigate(Routes.product(it)) }, @@ -223,6 +269,7 @@ fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { composable(Routes.Account) { AccountScreen( sessionState = sessionState, + canUseCustomerFeatures = customerAccess, onLogin = { navController.navigate(Routes.Login) }, onOrders = { navController.navigate(Routes.Orders) }, onRetryRestore = sessionViewModel::retryRestore, @@ -242,6 +289,7 @@ fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { productId, container.catalogRepository, container.cartRepository, + createSavedStateHandle(), ) } }, @@ -250,13 +298,16 @@ fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { ProductDetailScreen( state = state, signedIn = signedIn, + customerAccess = customerAccess, sessionRestoring = sessionRestoring, onBack = { navController.popBackStack() }, onRetry = vm::load, onChangeQuantity = vm::changeQuantity, onAddToCart = { - if (signedIn) vm.addToCart() - else navController.navigate(Routes.Login) + when { + !signedIn -> navController.navigate(Routes.Login) + customerAccess -> vm.addToCart() + } }, onConsumeNotice = vm::consumeNotice, ) @@ -287,16 +338,21 @@ fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { SessionState.SigningOut -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } - is SessionState.SignedIn -> { + is SessionState.SignedIn -> if (customerAccess) { val vm: OrdersViewModel = viewModel( + key = "orders-$customerSessionScope", factory = viewModelFactory { initializer { OrdersViewModel(container.orderRepository) } }, ) val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(customerUserId) { vm.load() } OrdersScreen( state = state, onLoad = vm::load, + onLoadMore = vm::loadMore, onBack = { navController.popBackStack() }, ) + } else { + CustomerOnlyGate() } else -> LaunchedEffect(Unit) { navController.navigate(Routes.Login) { @@ -308,3 +364,14 @@ fun EShopApp(container: AppContainer, modifier: Modifier = Modifier) { } } } + +@Composable +private fun CustomerOnlyGate() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "此功能仅面向顾客账户", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + ) + } +} diff --git a/android/app/src/test/java/com/class2/group1/eshop/core/session/SessionManagerTest.kt b/android/app/src/test/java/com/class2/group1/eshop/core/session/SessionManagerTest.kt index 8a2d7f6..fd396f8 100644 --- a/android/app/src/test/java/com/class2/group1/eshop/core/session/SessionManagerTest.kt +++ b/android/app/src/test/java/com/class2/group1/eshop/core/session/SessionManagerTest.kt @@ -1,6 +1,7 @@ package com.class2.group1.eshop.core.session import com.class2.group1.eshop.core.network.NativeAuthApi +import com.class2.group1.eshop.core.network.ApiException import com.class2.group1.eshop.core.security.RefreshTokenStore import com.class2.group1.eshop.data.remote.dto.ApiEnvelope import com.class2.group1.eshop.data.remote.dto.LoginRequestDto @@ -51,6 +52,157 @@ class SessionManagerTest { assertTrue(manager.state.value is SessionState.SignedIn) } + @Test + fun `second unauthorized response invalidates the rotated session`() = runTest { + val store = FakeRefreshTokenStore() + val api = FakeNativeAuthApi( + loginPayload = tokenPayload("access-1", "refresh-1", "alice"), + refreshPayload = tokenPayload("access-2", "refresh-2", "alice"), + ) + val manager = SessionManager(api, store, Gson()) + manager.login("alice", "secret") + val authorizations = mutableListOf() + + val response = manager.authorizedRequest { authorization -> + authorizations += authorization + Response.error(401, "{}".toResponseBody("application/json".toMediaType())) + } + + assertEquals(401, response.code()) + assertEquals(listOf("Bearer access-1", "Bearer access-2"), authorizations) + assertEquals(1, api.refreshCalls) + assertEquals(null, store.token) + assertTrue(manager.state.value is SessionState.SignedOut) + } + + @Test + fun `missing refresh token after unauthorized response signs out locally`() = runTest { + val store = FakeRefreshTokenStore() + val api = FakeNativeAuthApi( + loginPayload = tokenPayload("access-1", "refresh-1", "alice"), + refreshPayload = tokenPayload("access-2", "refresh-2", "alice"), + ) + val manager = SessionManager(api, store, Gson()) + manager.login("alice", "secret") + store.token = null + + val error = runCatching { + manager.authorizedRequest { + Response.error(401, "{}".toResponseBody("application/json".toMediaType())) + } + }.exceptionOrNull() + + assertTrue(error is ApiException) + assertEquals("COMMON_UNAUTHORIZED", (error as ApiException).code) + assertEquals(null, store.token) + assertTrue(manager.state.value is SessionState.SignedOut) + } + + @Test + fun `restore never leases the previous user's access token`() = runTest { + val refreshReached = CompletableDeferred() + val releaseRefresh = CompletableDeferred() + val store = FakeRefreshTokenStore() + val api = FakeNativeAuthApi( + loginPayload = tokenPayload("alice-access", "alice-refresh", "alice"), + refreshPayload = tokenPayload("bob-access", "bob-refresh", "bob"), + beforeRefresh = { + refreshReached.complete(Unit) + releaseRefresh.await() + }, + ) + val manager = SessionManager(api, store, Gson()) + manager.login("alice", "secret") + store.token = "bob-refresh" + + val restore = async { manager.restore() } + refreshReached.await() + val access = async { manager.requireAccessToken() } + runCurrent() + + assertTrue(!access.isCompleted) + releaseRefresh.complete(Unit) + restore.await() + + assertEquals("bob-access", access.await()) + assertEquals("bob", (manager.state.value as SessionState.SignedIn).user.username) + } + + @Test + fun `newer restore keeps the refresh token rotated by an older restore`() = runTest { + val refreshReached = CompletableDeferred() + val releaseRefresh = CompletableDeferred() + val store = FakeRefreshTokenStore("refresh-1") + val api = FakeNativeAuthApi( + loginPayload = tokenPayload("unused-access", "unused-refresh", "alice"), + refreshPayload = tokenPayload("access-2", "refresh-2", "alice"), + beforeRefresh = { + refreshReached.complete(Unit) + releaseRefresh.await() + }, + ) + val manager = SessionManager(api, store, Gson()) + + val olderRestore = async { manager.restore() } + refreshReached.await() + val newerRestore = async { manager.restore() } + runCurrent() + releaseRefresh.complete(Unit) + olderRestore.await() + newerRestore.await() + + assertEquals(2, api.refreshCalls) + assertEquals("refresh-2", store.token) + assertEquals("alice", (manager.state.value as SessionState.SignedIn).user.username) + } + + @Test + fun `late second unauthorized response does not invalidate a newer token`() = runTest { + val firstRequestSecondAttempt = CompletableDeferred() + val releaseFirstRequest = CompletableDeferred() + val store = FakeRefreshTokenStore() + val api = FakeNativeAuthApi( + loginPayload = tokenPayload("access-1", "refresh-1", "alice"), + refreshPayload = tokenPayload("access-2", "refresh-2", "alice"), + queuedRefreshPayloads = listOf( + tokenPayload("access-2", "refresh-2", "alice"), + tokenPayload("access-3", "refresh-3", "alice"), + ), + ) + val manager = SessionManager(api, store, Gson()) + manager.login("alice", "secret") + + val staleRequest = async { + manager.authorizedRequest { authorization -> + when (authorization) { + "Bearer access-1" -> unauthorized() + "Bearer access-2" -> { + firstRequestSecondAttempt.complete(Unit) + releaseFirstRequest.await() + unauthorized() + } + else -> error("Unexpected stale-request token: $authorization") + } + } + } + firstRequestSecondAttempt.await() + + val currentResponse = manager.authorizedRequest { authorization -> + when (authorization) { + "Bearer access-2" -> unauthorized() + "Bearer access-3" -> Response.success(okEnvelope("current")) + else -> error("Unexpected current-request token: $authorization") + } + } + releaseFirstRequest.complete(Unit) + + assertEquals("current", currentResponse.body()?.data) + assertEquals(401, staleRequest.await().code()) + assertEquals("refresh-3", store.token) + assertEquals("access-3", manager.requireAccessToken()) + assertTrue(manager.state.value is SessionState.SignedIn) + } + @Test fun `new login wins when an older restore completes late`() = runTest { val refreshReached = CompletableDeferred() @@ -94,8 +246,10 @@ class SessionManagerTest { private val loginPayload: NativeTokenDto, private val refreshPayload: NativeTokenDto, private val beforeRefresh: suspend () -> Unit = {}, + private val queuedRefreshPayloads: List? = null, ) : NativeAuthApi { var refreshCalls = 0 + private var refreshPayloadIndex = 0 override suspend fun login(request: LoginRequestDto) = Response.success(okEnvelope(loginPayload)) @@ -103,7 +257,10 @@ class SessionManagerTest { override suspend fun refresh(request: RefreshRequestDto): Response> { refreshCalls += 1 beforeRefresh() - return Response.success(okEnvelope(refreshPayload)) + val payload = queuedRefreshPayloads + ?.getOrNull(refreshPayloadIndex++) + ?: refreshPayload + return Response.success(okEnvelope(payload)) } override suspend fun logout(request: LogoutRequestDto): Response> = @@ -131,5 +288,8 @@ class SessionManagerTest { ) fun okEnvelope(data: T): ApiEnvelope = ApiEnvelope("OK", "ok", data, TRACE_ID) + + fun unauthorized(): Response> = + Response.error(401, "{}".toResponseBody("application/json".toMediaType())) } } diff --git a/android/app/src/test/java/com/class2/group1/eshop/feature/catalog/CatalogViewModelTest.kt b/android/app/src/test/java/com/class2/group1/eshop/feature/catalog/CatalogViewModelTest.kt index fcc7b65..a4f40e8 100644 --- a/android/app/src/test/java/com/class2/group1/eshop/feature/catalog/CatalogViewModelTest.kt +++ b/android/app/src/test/java/com/class2/group1/eshop/feature/catalog/CatalogViewModelTest.kt @@ -55,6 +55,8 @@ class CatalogViewModelTest { valuePicks = emptyList(), ) + override suspend fun listCategories() = listOf(Category("1", "精选", null)) + override suspend fun listProducts( page: Int, pageSize: Int, diff --git a/android/app/src/test/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModelTest.kt b/android/app/src/test/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModelTest.kt new file mode 100644 index 0000000..2f28e26 --- /dev/null +++ b/android/app/src/test/java/com/class2/group1/eshop/feature/catalog/ProductDetailViewModelTest.kt @@ -0,0 +1,105 @@ +package com.class2.group1.eshop.feature.catalog + +import androidx.lifecycle.SavedStateHandle +import com.class2.group1.eshop.domain.model.Cart +import com.class2.group1.eshop.domain.model.Category +import com.class2.group1.eshop.domain.model.HomeCatalog +import com.class2.group1.eshop.domain.model.ProductDetail +import com.class2.group1.eshop.domain.model.ProductPage +import com.class2.group1.eshop.domain.repository.CartRepository +import com.class2.group1.eshop.domain.repository.CatalogRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.Assert.assertEquals +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ProductDetailViewModelTest { + @Test + fun `rapid add clicks submit once with the quantity captured at click time`() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + try { + val cart = RecordingCartRepository() + val viewModel = ProductDetailViewModel( + productId = "p-1", + catalogRepository = ImmediateCatalogRepository(), + cartRepository = cart, + savedStateHandle = SavedStateHandle(mapOf("product.quantity" to 2)), + ) + + viewModel.addToCart() + viewModel.addToCart() + viewModel.changeQuantity(1) + advanceUntilIdle() + + assertEquals(listOf("p-1" to 2), cart.addCalls) + assertEquals("已加入购物车", viewModel.state.value.notice) + } finally { + Dispatchers.resetMain() + } + } + + private class ImmediateCatalogRepository : CatalogRepository { + override suspend fun loadHome() = HomeCatalog(emptyList(), emptyList(), emptyList()) + + override suspend fun listCategories(): List = emptyList() + + override suspend fun listProducts( + page: Int, + pageSize: Int, + categoryId: String?, + keyword: String?, + sortBy: String?, + sortOrder: String?, + ): ProductPage = error("not used") + + override suspend fun getProduct(productId: String) = ProductDetail( + id = productId, + sku = "SKU-1", + name = "测试商品", + description = null, + price = "10.00", + basePrice = "10.00", + promotion = null, + serverNow = "2026-08-11T00:00:00Z", + stock = 10, + inStock = true, + status = "active", + categoryId = "c-1", + categoryName = "测试分类", + brand = null, + salesCount = 0, + specificationsJson = null, + images = emptyList(), + createdAt = "2026-08-11T00:00:00Z", + ) + } + + private class RecordingCartRepository : CartRepository { + val addCalls = mutableListOf>() + + override suspend fun getCart() = emptyCart() + + override suspend fun addItem(productId: String, quantity: Int, knownEtag: String?): Cart { + addCalls += productId to quantity + return emptyCart() + } + + override suspend fun updateQuantity(itemId: String, quantity: Int, etag: String) = emptyCart() + + override suspend fun removeItem(itemId: String, etag: String) = emptyCart() + + private fun emptyCart() = Cart( + items = emptyList(), + totalCount = 0, + estimatedTotal = "0.00", + serverNow = "2026-08-11T00:00:00Z", + etag = "\"v0\"", + ) + } +} diff --git a/android/app/src/test/java/com/class2/group1/eshop/navigation/SessionScopedViewModelKeyTest.kt b/android/app/src/test/java/com/class2/group1/eshop/navigation/SessionScopedViewModelKeyTest.kt new file mode 100644 index 0000000..0f2afc3 --- /dev/null +++ b/android/app/src/test/java/com/class2/group1/eshop/navigation/SessionScopedViewModelKeyTest.kt @@ -0,0 +1,36 @@ +package com.class2.group1.eshop.navigation + +import com.class2.group1.eshop.core.session.SessionState +import com.class2.group1.eshop.domain.model.User +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SessionScopedViewModelKeyTest { + @Test + fun `customer scope changes with both user and login generation`() { + val alice = customer("1", "alice") + val bob = customer("2", "bob") + + val aliceFirst = customerSessionScopeKey(SessionState.SignedIn(alice, sessionKey = 11)) + val aliceSecond = customerSessionScopeKey(SessionState.SignedIn(alice, sessionKey = 12)) + val bobScope = customerSessionScopeKey(SessionState.SignedIn(bob, sessionKey = 13)) + + assertNotEquals(aliceFirst, aliceSecond) + assertNotEquals(aliceSecond, bobScope) + assertEquals("anonymous", customerSessionScopeKey(SessionState.SignedOut)) + assertEquals( + "anonymous", + customerSessionScopeKey( + SessionState.SignedIn(alice.copy(role = "merchant"), sessionKey = 14), + ), + ) + } + + private fun customer(id: String, username: String) = User( + id = id, + username = username, + displayName = null, + role = "customer", + ) +} -- Gitee From 907e506cc4ca3b7405ce6c46871376883025589c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:49:32 +0800 Subject: [PATCH 03/33] fix(customer-service): enforce conversation guards --- ...tomerServiceConversationGuards.Designer.cs | 2615 +++++++++++++++++ ...15_AddCustomerServiceConversationGuards.cs | 138 + .../Migrations/EShopDbContextModelSnapshot.cs | 14 +- .../Api/CustomerServiceEndpoints.cs | 12 +- .../Application/Dtos/CustomerServiceDtos.cs | 6 +- .../UseCases/CustomerServiceUseCases.cs | 56 +- .../Domain/Conversation.cs | 1 + .../Domain/ConversationMessage.cs | 4 +- .../ConversationConfiguration.cs | 18 + .../ConversationMessageConfiguration.cs | 2 +- .../BackendStructureTests.cs | 7 +- .../CreateConversationUseCaseTests.cs | 23 +- 12 files changed, 2870 insertions(+), 26 deletions(-) create mode 100644 backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.Designer.cs create mode 100644 backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.cs diff --git a/backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.Designer.cs b/backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.Designer.cs new file mode 100644 index 0000000..3c0728d --- /dev/null +++ b/backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.Designer.cs @@ -0,0 +1,2615 @@ +// +using System; +using EShop.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace EShop.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(EShopDbContext))] + [Migration("20260810154915_AddCustomerServiceConversationGuards")] + partial class AddCustomerServiceConversationGuards + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("EShop.Modules.Audit.Domain.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionCode") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("ActorRoleCode") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("ActorUserId") + .HasColumnType("bigint"); + + b.Property("AfterSnapshot") + .HasColumnType("jsonb"); + + b.Property("BeforeSnapshot") + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("ModuleCode") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("Outcome") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("RequestSummary") + .HasColumnType("jsonb"); + + b.Property("ResultCode") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("TargetId") + .HasColumnType("bigint"); + + b.Property("TargetType") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("TraceId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ActorUserId" }, "ix_audit_log_actor"); + + b.HasIndex(new[] { "CreatedAt" }, "ix_audit_log_created"); + + b.HasIndex(new[] { "ModuleCode", "ActionCode" }, "ix_audit_log_module_action"); + + b.HasIndex(new[] { "TargetType", "TargetId" }, "ix_audit_log_target"); + + b.ToTable("audit_log", "audit", t => + { + t.HasCheckConstraint("ck_audit_log_outcome", "\"Outcome\" IN ('SUCCEEDED', 'REJECTED', 'FAILED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Cart.Domain.CartItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CartId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("Quantity") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ProductId" }, "ix_cart_item_product_id"); + + b.HasIndex(new[] { "CartId", "ProductId" }, "uk_cart_item_cart_product") + .IsUnique(); + + b.ToTable("cart_item", "cart", t => + { + t.HasCheckConstraint("ck_cart_item_quantity", "\"Quantity\" > 0 AND \"Quantity\" <= 999"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Cart.Domain.ShoppingCart", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "UserId" }, "uk_shopping_cart_user_id") + .IsUnique(); + + b.ToTable("shopping_cart", "cart"); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.BrowseHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FirstViewedAt") + .HasColumnType("timestamptz"); + + b.Property("LastViewedAt") + .HasColumnType("timestamptz"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("ViewCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex(new[] { "UserId", "LastViewedAt", "Id" }, "ix_browse_history_user_viewed") + .IsDescending(false, true, true); + + b.HasIndex(new[] { "UserId", "ProductId" }, "uk_browse_history_user_product") + .IsUnique(); + + b.ToTable("browse_history", "catalog", t => + { + t.HasCheckConstraint("ck_browse_history_count", "\"ViewCount\" > 0"); + + t.HasCheckConstraint("ck_browse_history_order", "\"FirstViewedAt\" <= \"LastViewedAt\""); + }); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.CatalogInventoryIdempotencyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanonicalRequestHash") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("CompletedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FencingVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("HttpStatus") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamptz"); + + b.Property("OperationId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("OwnerToken") + .IsConcurrencyToken() + .HasColumnType("varchar(64)"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("ResourceId") + .HasColumnType("bigint"); + + b.Property("ResourceType") + .HasColumnType("varchar(64)"); + + b.Property("ResponseEnvelope") + .HasColumnType("jsonb"); + + b.Property("ResponseEtag") + .HasColumnType("varchar(64)"); + + b.Property("ResultCode") + .HasColumnType("varchar(64)"); + + b.Property("State") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("varchar(36)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "LeaseExpiresAt", "Id" }, "ix_catalog_inventory_idempotency_pending_lease") + .HasFilter("\"State\" = 'PENDING'"); + + b.HasIndex(new[] { "ProductId", "CreatedAt", "Id" }, "ix_catalog_inventory_idempotency_product_created"); + + b.HasIndex(new[] { "UserId", "OperationId", "Key" }, "uk_catalog_inventory_idempotency_user_op_key") + .IsUnique(); + + b.ToTable("inventory_idempotency_request", "catalog", t => + { + t.HasCheckConstraint("ck_catalog_inventory_idempotency_operation", "\"OperationId\" IN ('Catalog_AdminAdjustInventory', 'Catalog_MerchantAdjustInventory')"); + + t.HasCheckConstraint("ck_catalog_inventory_idempotency_state", "\"State\" IN ('PENDING', 'COMPLETED', 'RECOVERY_REQUIRED')"); + + t.HasCheckConstraint("ck_catalog_inventory_idempotency_state_consistency", "(\n \"State\" = 'PENDING'\n AND \"OwnerToken\" IS NOT NULL\n AND \"LeaseExpiresAt\" IS NOT NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NULL\n)\nOR\n(\n \"State\" = 'COMPLETED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NOT NULL\n AND \"ResultCode\" = 'OK'\n AND \"ResourceType\" IS NOT NULL\n AND \"ResourceId\" IS NOT NULL\n AND \"ResponseEnvelope\" IS NOT NULL\n AND \"ResponseEtag\" IS NOT NULL\n AND \"CompletedAt\" IS NOT NULL\n)\nOR\n(\n \"State\" = 'RECOVERY_REQUIRED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NOT NULL\n)"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("DeletedAt") + .HasColumnType("timestamptz"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("ParentId") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex("ParentId") + .HasDatabaseName("ix_category_parent_id"); + + b.HasIndex(new[] { "SortOrder" }, "ix_category_sort"); + + b.HasIndex(new[] { "NormalizedName" }, "uk_category_normalized_name") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.ToTable("category", "catalog", t => + { + t.HasCheckConstraint("ck_category_status", "\"Status\" IN ('active', 'inactive')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasColumnType("varchar(100)"); + + b.Property("CategoryId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("DeletedAt") + .HasColumnType("timestamptz"); + + b.Property("Description") + .HasColumnType("varchar(2000)"); + + b.Property("MerchantId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("PromotionEndsAt") + .HasColumnType("timestamptz"); + + b.Property("PromotionName") + .HasColumnType("varchar(80)"); + + b.Property("PromotionPrice") + .HasColumnType("numeric(12,2)"); + + b.Property("PromotionStartsAt") + .HasColumnType("timestamptz"); + + b.Property("SalesCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Sku") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("SpecificationsJson") + .HasColumnType("jsonb"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("StockQuantity") + .HasColumnType("integer"); + + b.Property("UnitPrice") + .HasColumnType("numeric(12,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "CategoryId" }, "ix_product_category"); + + b.HasIndex(new[] { "MerchantId" }, "ix_product_merchant"); + + b.HasIndex(new[] { "Status" }, "ix_product_status"); + + b.HasIndex(new[] { "Sku" }, "uk_product_sku") + .IsUnique(); + + b.ToTable("product", "catalog", t => + { + t.HasCheckConstraint("ck_product_price", "\"UnitPrice\" > 0"); + + t.HasCheckConstraint("ck_product_promotion_complete", "(\"PromotionName\" IS NULL AND \"PromotionPrice\" IS NULL AND \"PromotionStartsAt\" IS NULL AND \"PromotionEndsAt\" IS NULL) OR (\"PromotionName\" IS NOT NULL AND \"PromotionPrice\" IS NOT NULL AND \"PromotionStartsAt\" IS NOT NULL AND \"PromotionEndsAt\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_product_promotion_name", "\"PromotionName\" IS NULL OR (char_length(btrim(\"PromotionName\")) BETWEEN 1 AND 80 AND \"PromotionName\" = btrim(\"PromotionName\"))"); + + t.HasCheckConstraint("ck_product_promotion_period", "\"PromotionStartsAt\" IS NULL OR \"PromotionStartsAt\" < \"PromotionEndsAt\""); + + t.HasCheckConstraint("ck_product_promotion_price", "\"PromotionPrice\" IS NULL OR (\"PromotionPrice\" > 0 AND \"PromotionPrice\" < \"UnitPrice\")"); + + t.HasCheckConstraint("ck_product_sales", "\"SalesCount\" >= 0"); + + t.HasCheckConstraint("ck_product_status", "\"Status\" IN ('active', 'inactive')"); + + t.HasCheckConstraint("ck_product_stock", "\"StockQuantity\" BETWEEN 0 AND 100000000"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.ProductFavorite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ProductId"); + + b.HasIndex(new[] { "UserId" }, "ix_product_favorite_user"); + + b.HasIndex(new[] { "UserId", "ProductId" }, "uk_product_favorite_user_product") + .IsUnique(); + + b.ToTable("product_favorite", "catalog"); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.ProductImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContentHash") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("IsCover") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("PublicUrl") + .HasColumnType("varchar(500)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StorageKey") + .IsRequired() + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ProductId" }, "ix_product_image_product"); + + b.HasIndex(new[] { "ProductId" }, "uk_product_image_cover") + .IsUnique() + .HasFilter("\"IsCover\" = TRUE"); + + b.HasIndex(new[] { "ProductId", "SortOrder" }, "uk_product_image_sort") + .IsUnique(); + + b.HasIndex(new[] { "StorageKey" }, "uk_product_image_storage_key") + .IsUnique(); + + b.ToTable("product_image", "catalog"); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.ProductReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("varchar(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.PrimitiveCollection("Images") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("Rating") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ProductId", "CreatedAt" }, "ix_product_review_product_created") + .IsDescending(false, true); + + b.HasIndex(new[] { "UserId" }, "ix_product_review_user"); + + b.HasIndex(new[] { "OrderId", "ProductId" }, "uk_product_review_order_product") + .IsUnique(); + + b.ToTable("product_review", "catalog", t => + { + t.HasCheckConstraint("ck_product_review_rating", "\"Rating\" >= 1 AND \"Rating\" <= 5"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.StockMovement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActorRoleCode") + .HasColumnType("varchar(32)"); + + b.Property("ActorUserId") + .HasColumnType("bigint"); + + b.Property("BusinessKey") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("BusinessType") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedByRequestId") + .HasColumnType("bigint"); + + b.Property("Note") + .HasColumnType("varchar(200)"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("QuantityAfter") + .HasColumnType("integer"); + + b.Property("QuantityBefore") + .HasColumnType("integer"); + + b.Property("QuantityDelta") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "CreatedAt", "Id" }, "ix_stock_movement_created") + .IsDescending(); + + b.HasIndex(new[] { "ProductId" }, "ix_stock_movement_product"); + + b.HasIndex(new[] { "ProductId", "CreatedAt", "Id" }, "ix_stock_movement_product_created") + .IsDescending(false, true, true); + + b.HasIndex(new[] { "CreatedByRequestId" }, "uk_stock_movement_created_by_inventory_request") + .IsUnique() + .HasFilter("\"CreatedByRequestId\" IS NOT NULL"); + + b.HasIndex(new[] { "BusinessType", "BusinessKey", "ProductId" }, "uk_stock_movement_scope") + .IsUnique() + .HasFilter("\"BusinessType\" <> 'manual_adjustment'"); + + b.ToTable("stock_movement", "catalog", t => + { + t.HasCheckConstraint("ck_stock_movement_delta", "\"QuantityDelta\" <> 0"); + + t.HasCheckConstraint("ck_stock_movement_manual_request", "(\"BusinessType\" = 'manual_adjustment' AND \"CreatedByRequestId\" IS NOT NULL AND \"ActorUserId\" IS NOT NULL AND \"ActorRoleCode\" IS NOT NULL) OR (\"BusinessType\" <> 'manual_adjustment' AND \"CreatedByRequestId\" IS NULL)"); + + t.HasCheckConstraint("ck_stock_movement_quantities", "\"QuantityBefore\" BETWEEN 0 AND 100000000 AND \"QuantityAfter\" BETWEEN 0 AND 100000000 AND \"QuantityAfter\" = \"QuantityBefore\" + \"QuantityDelta\""); + }); + }); + + modelBuilder.Entity("EShop.Modules.Coupon.Domain.CouponClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("LockedAt") + .HasColumnType("timestamptz"); + + b.Property("LockedByRequestId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("TemplateId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UsedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("ValidFrom") + .HasColumnType("timestamptz"); + + b.Property("ValidUntil") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "UserId", "Status", "ValidUntil", "Id" }, "ix_coupon_claim_user_status"); + + b.HasIndex(new[] { "LockedByRequestId" }, "uk_coupon_claim_locked_request") + .IsUnique() + .HasFilter("\"LockedByRequestId\" IS NOT NULL"); + + b.HasIndex(new[] { "TemplateId", "UserId" }, "uk_coupon_claim_template_user") + .IsUnique(); + + b.ToTable("coupon_claim", "coupon", t => + { + t.HasCheckConstraint("ck_coupon_claim_lock_state", "(\"Status\" = 'AVAILABLE' AND \"LockedByRequestId\" IS NULL AND \"LockedAt\" IS NULL AND \"UsedAt\" IS NULL) OR (\"Status\" = 'LOCKED' AND \"LockedByRequestId\" IS NOT NULL AND \"LockedAt\" IS NOT NULL AND \"UsedAt\" IS NULL) OR (\"Status\" = 'USED' AND \"LockedByRequestId\" IS NOT NULL AND \"LockedAt\" IS NOT NULL AND \"UsedAt\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_coupon_claim_period", "\"ValidFrom\" < \"ValidUntil\""); + + t.HasCheckConstraint("ck_coupon_claim_status", "\"Status\" IN ('AVAILABLE', 'LOCKED', 'USED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Coupon.Domain.CouponRedemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("DiscountAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("LockedAt") + .HasColumnType("timestamptz"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("OrderingRequestId") + .HasColumnType("bigint"); + + b.Property("ReleaseReason") + .HasColumnType("varchar(64)"); + + b.Property("ReleasedAt") + .HasColumnType("timestamptz"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("TemplateId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UsedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ClaimId", "Status" }, "ix_coupon_redemption_claim_status"); + + b.HasIndex(new[] { "ClaimId" }, "uk_coupon_redemption_active_claim") + .IsUnique() + .HasFilter("\"Status\" IN ('LOCKED', 'USED')"); + + b.HasIndex(new[] { "OrderId" }, "uk_coupon_redemption_order_id") + .IsUnique(); + + b.HasIndex(new[] { "OrderingRequestId" }, "uk_coupon_redemption_ordering_request") + .IsUnique(); + + b.ToTable("coupon_redemption", "coupon", t => + { + t.HasCheckConstraint("ck_coupon_redemption_amount", "\"DiscountAmount\" > 0"); + + t.HasCheckConstraint("ck_coupon_redemption_state", "(\"Status\" = 'LOCKED' AND \"UsedAt\" IS NULL AND \"ReleasedAt\" IS NULL AND \"ReleaseReason\" IS NULL) OR (\"Status\" = 'USED' AND \"UsedAt\" IS NOT NULL AND \"ReleasedAt\" IS NULL AND \"ReleaseReason\" IS NULL) OR (\"Status\" = 'RELEASED' AND \"UsedAt\" IS NULL AND \"ReleasedAt\" IS NOT NULL AND \"ReleaseReason\" IS NOT NULL)"); + + t.HasCheckConstraint("ck_coupon_redemption_status", "\"Status\" IN ('LOCKED', 'USED', 'RELEASED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Coupon.Domain.CouponTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowWithProductPromotion") + .HasColumnType("boolean"); + + b.Property("AmountOff") + .HasColumnType("numeric(12,2)"); + + b.Property("BenefitType") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("ClaimedQuantity") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Description") + .HasColumnType("varchar(500)"); + + b.Property("DiscountRateBps") + .HasColumnType("integer"); + + b.Property("MaxDiscountAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("ThresholdAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("TotalQuantity") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("ValidFrom") + .HasColumnType("timestamptz"); + + b.Property("ValidUntil") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Status", "ValidFrom", "ValidUntil", "Id" }, "ix_coupon_template_available"); + + b.HasIndex(new[] { "Code" }, "uk_coupon_template_code") + .IsUnique(); + + b.ToTable("coupon_template", "coupon", t => + { + t.HasCheckConstraint("ck_coupon_template_benefit_type", "\"BenefitType\" IN ('FIXED', 'PERCENT')"); + + t.HasCheckConstraint("ck_coupon_template_period", "\"ValidFrom\" < \"ValidUntil\""); + + t.HasCheckConstraint("ck_coupon_template_quantity", "\"ClaimedQuantity\" >= 0 AND (\"TotalQuantity\" IS NULL OR (\"TotalQuantity\" > 0 AND \"ClaimedQuantity\" <= \"TotalQuantity\"))"); + + t.HasCheckConstraint("ck_coupon_template_rule", "(\"BenefitType\" = 'FIXED' AND \"AmountOff\" IS NOT NULL AND \"AmountOff\" > 0 AND \"DiscountRateBps\" IS NULL AND \"MaxDiscountAmount\" IS NULL) OR (\"BenefitType\" = 'PERCENT' AND \"AmountOff\" IS NULL AND \"DiscountRateBps\" IS NOT NULL AND \"DiscountRateBps\" > 0 AND \"DiscountRateBps\" < 10000 AND (\"MaxDiscountAmount\" IS NULL OR \"MaxDiscountAmount\" > 0))"); + + t.HasCheckConstraint("ck_coupon_template_status", "\"Status\" IN ('DRAFT', 'ACTIVE', 'INACTIVE')"); + + t.HasCheckConstraint("ck_coupon_template_threshold", "\"ThresholdAmount\" >= 0"); + }); + }); + + modelBuilder.Entity("EShop.Modules.CustomerService.Domain.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContextType") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CustomerUserId") + .HasColumnType("bigint"); + + b.Property("LastMessageAt") + .HasColumnType("timestamptz"); + + b.Property("MerchantId") + .HasColumnType("bigint"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PlatformIntervened") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PlatformIntervenedAt") + .HasColumnType("timestamptz"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("ProductName") + .HasColumnType("varchar(100)"); + + b.Property("ShopId") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "CustomerUserId" }, "ix_conversation_customer_user_id"); + + b.HasIndex(new[] { "MerchantId" }, "ix_conversation_merchant_id"); + + b.HasIndex(new[] { "ShopId" }, "ix_conversation_shop_id"); + + b.HasIndex(new[] { "Status" }, "ix_conversation_status"); + + b.HasIndex(new[] { "CustomerUserId", "ShopId", "OrderId" }, "uk_conversation_open_order_context") + .IsUnique() + .HasFilter("\"ContextType\" = 'ORDER' AND \"Status\" = 'OPEN' AND \"OrderId\" IS NOT NULL"); + + b.HasIndex(new[] { "CustomerUserId", "ShopId", "ProductId" }, "uk_conversation_open_product_context") + .IsUnique() + .HasFilter("\"ContextType\" = 'PRODUCT' AND \"Status\" = 'OPEN' AND \"ProductId\" IS NOT NULL"); + + b.HasIndex(new[] { "CustomerUserId", "ShopId" }, "uk_conversation_open_shop_context") + .IsUnique() + .HasFilter("\"ContextType\" = 'SHOP' AND \"Status\" = 'OPEN'"); + + b.ToTable("conversation", "customer_service", t => + { + t.HasCheckConstraint("ck_conversation_context_type", "\"ContextType\" IN ('SHOP', 'PRODUCT', 'ORDER')"); + + t.HasCheckConstraint("ck_conversation_status", "\"Status\" IN ('OPEN', 'CLOSED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.CustomerService.Domain.ConversationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("varchar(2000)"); + + b.Property("ConversationId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedByRequestId") + .HasColumnType("bigint"); + + b.Property("IsRead") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SenderRole") + .IsRequired() + .HasColumnType("varchar(16)"); + + b.Property("SenderUserId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ConversationId" }, "ix_conversation_message_conversation_id"); + + b.HasIndex(new[] { "CreatedByRequestId" }, "uk_conversation_message_created_by_request_id") + .IsUnique() + .HasFilter("\"CreatedByRequestId\" IS NOT NULL"); + + b.ToTable("conversation_message", "customer_service", t => + { + t.HasCheckConstraint("ck_conversation_message_sender_role", "\"SenderRole\" IN ('customer', 'merchant', 'admin')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.CustomerService.Domain.CustomerServiceIdempotencyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanonicalRequestHash") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("CompletedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FencingVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("HttpStatus") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamptz"); + + b.Property("OperationId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("OwnerToken") + .IsConcurrencyToken() + .HasColumnType("varchar(64)"); + + b.Property("ResourceId") + .HasColumnType("bigint"); + + b.Property("ResourceType") + .HasColumnType("varchar(64)"); + + b.Property("ResponseEnvelope") + .HasColumnType("jsonb"); + + b.Property("ResponseEtag") + .HasColumnType("varchar(64)"); + + b.Property("ResultCode") + .HasColumnType("varchar(64)"); + + b.Property("State") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("varchar(36)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id") + .HasName("pk_customer_service_idempotency_request"); + + b.HasIndex(new[] { "State", "LeaseExpiresAt" }, "ix_customer_service_idempotency_pending_lease"); + + b.HasIndex(new[] { "UserId", "OperationId", "Key" }, "uk_customer_service_idempotency_user_op_key") + .IsUnique(); + + b.ToTable("idempotency_request", "customer_service", t => + { + t.HasCheckConstraint("ck_customer_service_idempotency_state", "\"State\" IN ('PENDING', 'COMPLETED', 'RECOVERY_REQUIRED')"); + + t.HasCheckConstraint("ck_customer_service_idempotency_state_consistency", "(\n (\"State\" = 'PENDING'\n AND \"OwnerToken\" IS NOT NULL\n AND \"LeaseExpiresAt\" IS NOT NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NULL)\n OR\n (\"State\" = 'COMPLETED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NOT NULL\n AND \"ResultCode\" IS NOT NULL\n AND \"ResultCode\" = 'OK'\n AND \"ResourceType\" IS NOT NULL\n AND \"ResourceId\" IS NOT NULL\n AND \"ResponseEnvelope\" IS NOT NULL\n AND \"ResponseEtag\" IS NOT NULL\n AND \"CompletedAt\" IS NOT NULL)\n OR\n (\"State\" = 'RECOVERY_REQUIRED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NOT NULL)\n)"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Identity.Domain.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Nickname") + .HasColumnType("varchar(50)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("varchar(200)"); + + b.Property("Phone") + .HasColumnType("varchar(20)"); + + b.Property("RoleCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(8)") + .HasDefaultValue("customer"); + + b.Property("SecurityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(32)") + .HasDefaultValue("ACTIVE"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Username") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Status" }, "ix_app_user_status"); + + b.HasIndex(new[] { "Username" }, "uk_app_user_username") + .IsUnique(); + + b.ToTable("app_user", "identity", t => + { + t.HasCheckConstraint("ck_app_user_role_code", "\"RoleCode\" IN ('customer', 'admin', 'merchant')"); + + t.HasCheckConstraint("ck_app_user_status", "\"Status\" IN ('ACTIVE', 'DISABLED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Identity.Domain.AuthSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FamilyExpiresAt") + .HasColumnType("timestamptz"); + + b.Property("FamilyId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("Generation") + .HasColumnType("integer"); + + b.Property("IdleExpiresAt") + .HasColumnType("timestamptz"); + + b.Property("PepperVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("RefreshTokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("ReplacedByTokenId") + .HasColumnType("varchar(64)"); + + b.Property("ReplayDetectedAt") + .HasColumnType("timestamptz"); + + b.Property("RevokedAt") + .HasColumnType("timestamptz"); + + b.Property("RotatedAt") + .HasColumnType("timestamptz"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("UserSecurityVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "SessionId" }, "ix_auth_session_session_id"); + + b.HasIndex(new[] { "UserId" }, "ix_auth_session_user_id"); + + b.HasIndex(new[] { "UserId", "Status" }, "ix_auth_session_user_status"); + + b.HasIndex(new[] { "FamilyId", "Generation" }, "uk_auth_session_family_gen") + .IsUnique(); + + b.HasIndex(new[] { "TokenId" }, "uk_auth_session_token_id") + .IsUnique(); + + b.ToTable("auth_session", "identity", t => + { + t.HasCheckConstraint("ck_auth_session_status", "\"Status\" IN ('ACTIVE', 'ROTATED', 'REVOKED', 'EXPIRED', 'REPLAYED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Identity.Domain.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("varchar(6)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("ParentCode") + .HasColumnType("varchar(6)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Level" }, "ix_region_level"); + + b.HasIndex(new[] { "ParentCode" }, "ix_region_parent_code"); + + b.HasIndex(new[] { "Code" }, "uk_region_code") + .IsUnique(); + + b.ToTable("region", "identity"); + }); + + modelBuilder.Entity("EShop.Modules.Identity.Domain.ShippingAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("City") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("CityCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(10)") + .HasDefaultValue(""); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("DeletedAt") + .HasColumnType("timestamptz"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("varchar(200)"); + + b.Property("District") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("DistrictCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(10)") + .HasDefaultValue(""); + + b.Property("IsDefault") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Phone") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.Property("Province") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("ProvinceCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(10)") + .HasDefaultValue(""); + + b.Property("ReceiverName") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "ProvinceCode", "CityCode", "DistrictCode" }, "ix_shipping_address_region_codes"); + + b.HasIndex(new[] { "UserId" }, "ix_shipping_address_user_id"); + + b.HasIndex(new[] { "UserId" }, "uk_shipping_address_default") + .IsUnique() + .HasDatabaseName("uk_shipping_address_default") + .HasFilter("\"IsDefault\" = TRUE AND \"DeletedAt\" IS NULL"); + + b.ToTable("shipping_address", "identity"); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.LogisticsCarrier", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Code" }, "uk_logistics_carrier_code") + .IsUnique(); + + b.ToTable("logistics_carrier", "ordering"); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.OrderIdempotencyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanonicalRequestHash") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("CompletedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FencingVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("HttpStatus") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamptz"); + + b.Property("OperationId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("OwnerToken") + .IsConcurrencyToken() + .HasColumnType("varchar(64)"); + + b.Property("ResourceId") + .HasColumnType("bigint"); + + b.Property("ResourceType") + .HasColumnType("varchar(64)"); + + b.Property("ResponseEnvelope") + .HasColumnType("jsonb"); + + b.Property("ResponseEtag") + .HasColumnType("varchar(64)"); + + b.Property("ResultCode") + .HasColumnType("varchar(64)"); + + b.Property("State") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("varchar(36)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "UserId", "OperationId", "Key" }, "uk_order_idempotency_user_op_key") + .IsUnique(); + + b.ToTable("idempotency_request", "ordering", t => + { + t.HasCheckConstraint("ck_order_idempotency_state", "\"State\" IN ('PENDING', 'COMPLETED', 'RECOVERY_REQUIRED')"); + + t.HasCheckConstraint("ck_order_idempotency_state_consistency", "(\n \"State\" = 'PENDING'\n AND \"OwnerToken\" IS NOT NULL\n AND \"LeaseExpiresAt\" IS NOT NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NULL\n)\nOR\n(\n \"State\" = 'COMPLETED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NOT NULL\n AND \"ResultCode\" = 'OK'\n AND \"ResourceType\" IS NOT NULL\n AND \"ResourceId\" IS NOT NULL\n AND \"ResponseEnvelope\" IS NOT NULL\n AND \"ResponseEtag\" IS NOT NULL\n AND \"CompletedAt\" IS NOT NULL\n)\nOR\n(\n \"State\" = 'RECOVERY_REQUIRED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NOT NULL\n)"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.RefundRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("DisputeReason") + .HasColumnType("varchar(500)"); + + b.Property("DisputedAt") + .HasColumnType("timestamptz"); + + b.Property("IsDisputed") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("varchar(500)"); + + b.Property("RefundAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("ReturnCarrier") + .HasColumnType("varchar(64)"); + + b.Property("ReturnTrackingNumber") + .HasColumnType("varchar(64)"); + + b.Property("ReviewRemark") + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamptz"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "OrderId" }, "ix_refund_record_order_id"); + + b.HasIndex(new[] { "Status" }, "ix_refund_record_status"); + + b.HasIndex(new[] { "UserId" }, "ix_refund_record_user_id"); + + b.ToTable("refund_record", "ordering", t => + { + t.HasCheckConstraint("ck_refund_record_status", "\"Status\" IN ('PENDING', 'WAITING_RETURN', 'APPROVED', 'REJECTED', 'DISPUTED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.SalesOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AddressSnapshot") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CancelReason") + .HasColumnType("varchar(200)"); + + b.Property("CancelledAt") + .HasColumnType("timestamptz"); + + b.Property("Carrier") + .HasColumnType("varchar(64)"); + + b.Property("CompletedAt") + .HasColumnType("timestamptz"); + + b.Property("CouponAllowWithProductPromotionSnapshot") + .HasColumnType("boolean"); + + b.Property("CouponAmountOffSnapshot") + .HasColumnType("numeric(12,2)"); + + b.Property("CouponBenefitTypeSnapshot") + .HasColumnType("varchar(16)"); + + b.Property("CouponClaimId") + .HasColumnType("bigint"); + + b.Property("CouponCodeSnapshot") + .HasColumnType("varchar(64)"); + + b.Property("CouponDiscountAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("CouponDiscountRateBpsSnapshot") + .HasColumnType("integer"); + + b.Property("CouponMaxDiscountAmountSnapshot") + .HasColumnType("numeric(12,2)"); + + b.Property("CouponNameSnapshot") + .HasColumnType("varchar(100)"); + + b.Property("CouponThresholdAmountSnapshot") + .HasColumnType("numeric(12,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedByRequestId") + .HasColumnType("bigint"); + + b.Property("GoodsAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("IsDeletedByUser") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("MerchantId") + .HasColumnType("bigint"); + + b.Property("OrderNo") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("PaidAt") + .HasColumnType("timestamptz"); + + b.Property("PayableAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("PlacedAt") + .HasColumnType("timestamptz"); + + b.Property("Remark") + .HasColumnType("varchar(200)"); + + b.Property("ShippedAt") + .HasColumnType("timestamptz"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("TrackingNumber") + .HasColumnType("varchar(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "CouponClaimId" }, "ix_sales_order_coupon_claim_id"); + + b.HasIndex(new[] { "CreatedByRequestId" }, "ix_sales_order_created_by_request"); + + b.HasIndex(new[] { "MerchantId" }, "ix_sales_order_merchant_id"); + + b.HasIndex(new[] { "PaidAt", "Status", "Id" }, "ix_sales_order_paid_status"); + + b.HasIndex(new[] { "PlacedAt" }, "ix_sales_order_placed_at"); + + b.HasIndex(new[] { "Status" }, "ix_sales_order_status"); + + b.HasIndex(new[] { "UserId", "IsDeletedByUser", "Id" }, "ix_sales_order_user_deleted"); + + b.HasIndex(new[] { "UserId" }, "ix_sales_order_user_id"); + + b.HasIndex(new[] { "UserId", "PaidAt", "Id" }, "ix_sales_order_user_paid"); + + b.HasIndex(new[] { "OrderNo" }, "uk_sales_order_order_no") + .IsUnique(); + + b.ToTable("sales_order", "ordering", t => + { + t.HasCheckConstraint("ck_sales_order_coupon_amounts", "\"GoodsAmount\" >= 0 AND \"CouponDiscountAmount\" >= 0 AND \"CouponDiscountAmount\" <= \"GoodsAmount\" AND \"PayableAmount\" = \"GoodsAmount\" - \"CouponDiscountAmount\""); + + t.HasCheckConstraint("ck_sales_order_coupon_snapshot", "(\"CouponClaimId\" IS NULL AND \"CouponCodeSnapshot\" IS NULL AND \"CouponNameSnapshot\" IS NULL AND \"CouponBenefitTypeSnapshot\" IS NULL AND \"CouponThresholdAmountSnapshot\" IS NULL AND \"CouponAmountOffSnapshot\" IS NULL AND \"CouponDiscountRateBpsSnapshot\" IS NULL AND \"CouponMaxDiscountAmountSnapshot\" IS NULL AND \"CouponAllowWithProductPromotionSnapshot\" IS NULL AND \"CouponDiscountAmount\" = 0) OR (\"CouponClaimId\" IS NOT NULL AND \"CouponCodeSnapshot\" IS NOT NULL AND \"CouponNameSnapshot\" IS NOT NULL AND \"CouponBenefitTypeSnapshot\" IN ('FIXED', 'PERCENT') AND \"CouponThresholdAmountSnapshot\" IS NOT NULL AND \"CouponThresholdAmountSnapshot\" >= 0 AND \"CouponAllowWithProductPromotionSnapshot\" IS NOT NULL AND \"CouponDiscountAmount\" > 0 AND ((\"CouponBenefitTypeSnapshot\" = 'FIXED' AND \"CouponAmountOffSnapshot\" IS NOT NULL AND \"CouponAmountOffSnapshot\" > 0 AND \"CouponDiscountRateBpsSnapshot\" IS NULL AND \"CouponMaxDiscountAmountSnapshot\" IS NULL) OR (\"CouponBenefitTypeSnapshot\" = 'PERCENT' AND \"CouponAmountOffSnapshot\" IS NULL AND \"CouponDiscountRateBpsSnapshot\" IS NOT NULL AND \"CouponDiscountRateBpsSnapshot\" > 0 AND \"CouponDiscountRateBpsSnapshot\" < 10000 AND (\"CouponMaxDiscountAmountSnapshot\" IS NULL OR \"CouponMaxDiscountAmountSnapshot\" > 0))))"); + + t.HasCheckConstraint("ck_sales_order_shipping_details", "(\n \"Status\" IN ('SHIPPED', 'COMPLETED')\n AND \"TrackingNumber\" IS NOT NULL\n AND \"TrackingNumber\" ~ '[^[:space:]]'\n AND \"Carrier\" IS NOT NULL\n AND \"Carrier\" ~ '[^[:space:]]'\n)\nOR\n(\n \"Status\" NOT IN ('SHIPPED', 'COMPLETED')\n AND \"TrackingNumber\" IS NULL\n AND \"Carrier\" IS NULL\n)"); + + t.HasCheckConstraint("ck_sales_order_status", "\"Status\" IN ('PENDING_PAYMENT', 'PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.SalesOrderItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CategoryIdSnapshot") + .HasColumnType("bigint"); + + b.Property("CategoryNameSnapshot") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("CouponDiscountAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("DiscountAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("LineAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("OriginalUnitPrice") + .HasColumnType("numeric(12,2)"); + + b.Property("PayableLineAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("PriceEvaluatedAt") + .HasColumnType("timestamptz"); + + b.Property("ProductId") + .HasColumnType("bigint"); + + b.Property("ProductNameSnapshot") + .IsRequired() + .HasColumnType("varchar(200)"); + + b.Property("PromotionEndsAt") + .HasColumnType("timestamptz"); + + b.Property("PromotionNameSnapshot") + .HasColumnType("varchar(80)"); + + b.Property("PromotionStartsAt") + .HasColumnType("timestamptz"); + + b.Property("Quantity") + .HasColumnType("integer"); + + b.Property("SkuSnapshot") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("UnitPrice") + .HasColumnType("numeric(12,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.HasKey("Id"); + + b.HasIndex(new[] { "OrderId" }, "ix_sales_order_item_order_id"); + + b.HasIndex(new[] { "ProductId" }, "ix_sales_order_item_product_id"); + + b.ToTable("sales_order_item", "ordering", t => + { + t.HasCheckConstraint("ck_sales_order_item_coupon_amounts", "\"CouponDiscountAmount\" >= 0 AND \"CouponDiscountAmount\" <= \"LineAmount\" AND \"PayableLineAmount\" = \"LineAmount\" - \"CouponDiscountAmount\""); + + t.HasCheckConstraint("ck_sales_order_item_price_snapshot", "\"OriginalUnitPrice\" >= \"UnitPrice\" AND \"LineAmount\" = \"UnitPrice\" * \"Quantity\" AND \"DiscountAmount\" = (\"OriginalUnitPrice\" - \"UnitPrice\") * \"Quantity\""); + + t.HasCheckConstraint("ck_sales_order_item_promotion_name", "\"PromotionNameSnapshot\" IS NULL OR (char_length(btrim(\"PromotionNameSnapshot\")) BETWEEN 1 AND 80 AND \"PromotionNameSnapshot\" = btrim(\"PromotionNameSnapshot\"))"); + + t.HasCheckConstraint("ck_sales_order_item_promotion_snapshot", "(\"PromotionNameSnapshot\" IS NULL AND \"PromotionStartsAt\" IS NULL AND \"PromotionEndsAt\" IS NULL) OR (\"PromotionNameSnapshot\" IS NOT NULL AND \"PromotionStartsAt\" IS NOT NULL AND \"PromotionEndsAt\" IS NOT NULL AND \"PromotionStartsAt\" <= \"PriceEvaluatedAt\" AND \"PriceEvaluatedAt\" < \"PromotionEndsAt\")"); + + t.HasCheckConstraint("ck_sales_order_item_quantity", "\"Quantity\" > 0"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Payment.Domain.PaymentIdempotencyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanonicalRequestHash") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("CompletedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FencingVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("HttpStatus") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamptz"); + + b.Property("OperationId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("OwnerToken") + .IsConcurrencyToken() + .HasColumnType("varchar(64)"); + + b.Property("ResourceId") + .HasColumnType("bigint"); + + b.Property("ResourceType") + .HasColumnType("varchar(64)"); + + b.Property("ResponseEnvelope") + .HasColumnType("jsonb"); + + b.Property("ResponseEtag") + .HasColumnType("varchar(64)"); + + b.Property("ResultCode") + .HasColumnType("varchar(64)"); + + b.Property("State") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("varchar(36)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id") + .HasName("PK_idempotency_request1"); + + b.HasIndex(new[] { "UserId", "OperationId", "Key" }, "uk_payment_idempotency_user_op_key") + .IsUnique(); + + b.ToTable("idempotency_request", "payment", t => + { + t.HasCheckConstraint("ck_payment_idempotency_state", "\"State\" IN ('PENDING', 'COMPLETED', 'RECOVERY_REQUIRED')"); + + t.HasCheckConstraint("ck_payment_idempotency_state_consistency", "(\n (\"State\" = 'PENDING'\n AND \"OwnerToken\" IS NOT NULL\n AND \"LeaseExpiresAt\" IS NOT NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NULL)\n OR\n (\"State\" = 'COMPLETED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NOT NULL\n AND \"ResultCode\" = 'OK'\n AND \"ResourceType\" IS NOT NULL\n AND \"ResourceId\" IS NOT NULL\n AND \"ResponseEnvelope\" IS NOT NULL\n AND \"ResponseEtag\" IS NOT NULL\n AND \"CompletedAt\" IS NOT NULL)\n OR\n (\"State\" = 'RECOVERY_REQUIRED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NOT NULL)\n)"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Payment.Domain.PaymentRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("numeric(12,2)"); + + b.Property("CallbackCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("CallbackData") + .HasColumnType("jsonb"); + + b.Property("CallbackTime") + .HasColumnType("timestamptz"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedByRequestId") + .HasColumnType("bigint"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("PayTime") + .HasColumnType("timestamptz"); + + b.Property("PaymentNo") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("Provider") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(32)") + .HasDefaultValue("SIMULATED"); + + b.Property("ProviderRequestKey") + .HasColumnType("varchar(128)"); + + b.Property("ProviderTransactionId") + .HasColumnType("varchar(128)"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "CreatedByRequestId" }, "ix_payment_record_created_by_request"); + + b.HasIndex(new[] { "OrderId" }, "ix_payment_record_order_id"); + + b.HasIndex(new[] { "PaymentNo" }, "uk_payment_record_payment_no") + .IsUnique(); + + b.HasIndex(new[] { "Provider", "ProviderRequestKey" }, "uk_payment_record_provider_request") + .IsUnique() + .HasFilter("\"ProviderRequestKey\" IS NOT NULL"); + + b.HasIndex(new[] { "Provider", "ProviderTransactionId" }, "uk_payment_record_provider_txn") + .IsUnique() + .HasFilter("\"ProviderTransactionId\" IS NOT NULL"); + + b.ToTable("payment_record", "payment", t => + { + t.HasCheckConstraint("ck_payment_record_status", "\"Status\" IN ('CREATED', 'PROCESSING', 'SUCCEEDED', 'FAILED', 'CLOSED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Settlement.Domain.MerchantWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvailableBalance") + .HasColumnType("numeric(12,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FrozenBalance") + .HasColumnType("numeric(12,2)"); + + b.Property("MerchantId") + .HasColumnType("bigint"); + + b.Property("TotalIncome") + .HasColumnType("numeric(12,2)"); + + b.Property("TotalWithdrawn") + .HasColumnType("numeric(12,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "MerchantId" }, "uk_merchant_wallet_merchant_id") + .IsUnique(); + + b.ToTable("merchant_wallet", "settlement"); + }); + + modelBuilder.Entity("EShop.Modules.Settlement.Domain.SettlementIdempotencyRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanonicalRequestHash") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("CompletedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("FencingVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(1L); + + b.Property("HttpStatus") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasColumnType("varchar(128)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("timestamptz"); + + b.Property("OperationId") + .IsRequired() + .HasColumnType("varchar(64)"); + + b.Property("OwnerToken") + .IsConcurrencyToken() + .HasColumnType("varchar(64)"); + + b.Property("ResourceId") + .HasColumnType("bigint"); + + b.Property("ResourceType") + .HasColumnType("varchar(64)"); + + b.Property("ResponseEnvelope") + .HasColumnType("jsonb"); + + b.Property("ResponseEtag") + .HasColumnType("varchar(64)"); + + b.Property("ResultCode") + .HasColumnType("varchar(64)"); + + b.Property("State") + .IsConcurrencyToken() + .IsRequired() + .HasColumnType("varchar(36)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id") + .HasName("PK_idempotency_request2"); + + b.HasIndex(new[] { "UserId", "OperationId", "Key" }, "uk_settlement_idempotency_user_op_key") + .IsUnique(); + + b.ToTable("idempotency_request", "settlement", t => + { + t.HasCheckConstraint("ck_settlement_idempotency_operation", "\"OperationId\" IN ('Settlement_ApplyWithdrawal', 'Settlement_ApproveWithdrawal', 'Settlement_RejectWithdrawal', 'Settlement_MarkWithdrawalPaid')"); + + t.HasCheckConstraint("ck_settlement_idempotency_state", "\"State\" IN ('PENDING', 'COMPLETED', 'RECOVERY_REQUIRED')"); + + t.HasCheckConstraint("ck_settlement_idempotency_state_consistency", "(\n (\"State\" = 'PENDING'\n AND \"OwnerToken\" IS NOT NULL\n AND \"LeaseExpiresAt\" IS NOT NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NULL)\n OR\n (\"State\" = 'COMPLETED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NOT NULL\n AND \"ResultCode\" IS NOT NULL\n AND \"ResultCode\" = 'OK'\n AND \"ResourceType\" IS NOT NULL\n AND \"ResourceId\" IS NOT NULL\n AND \"ResponseEnvelope\" IS NOT NULL\n AND \"ResponseEtag\" IS NOT NULL\n AND \"CompletedAt\" IS NOT NULL)\n OR\n (\"State\" = 'RECOVERY_REQUIRED'\n AND \"OwnerToken\" IS NULL\n AND \"LeaseExpiresAt\" IS NULL\n AND \"HttpStatus\" IS NULL\n AND \"ResultCode\" IS NULL\n AND \"ResourceType\" IS NULL\n AND \"ResourceId\" IS NULL\n AND \"ResponseEnvelope\" IS NULL\n AND \"ResponseEtag\" IS NULL\n AND \"CompletedAt\" IS NOT NULL)\n)"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Settlement.Domain.SettlementRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CommissionAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("CommissionRate") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(5,4)") + .HasDefaultValue(0.05m); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("MerchantId") + .HasColumnType("bigint"); + + b.Property("NetAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("OrderAmount") + .HasColumnType("numeric(12,2)"); + + b.Property("OrderId") + .HasColumnType("bigint"); + + b.Property("SettledAt") + .HasColumnType("timestamptz"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "MerchantId" }, "ix_settlement_merchant_id"); + + b.HasIndex(new[] { "OrderId" }, "uk_settlement_order_id") + .IsUnique(); + + b.ToTable("settlement_record", "settlement", t => + { + t.HasCheckConstraint("ck_settlement_status", "\"Status\" IN ('PENDING', 'SETTLED', 'CANCELLED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Settlement.Domain.WithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("numeric(12,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("CreatedByRequestId") + .HasColumnType("bigint"); + + b.Property("MerchantId") + .HasColumnType("bigint"); + + b.Property("PaidAt") + .HasColumnType("timestamptz"); + + b.Property("PaymentAccount") + .IsRequired() + .HasColumnType("varchar(100)"); + + b.Property("ReviewRemark") + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamptz"); + + b.Property("ReviewedBy") + .HasColumnType("bigint"); + + b.Property("Status") + .IsRequired() + .HasColumnType("varchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "MerchantId" }, "ix_withdrawal_merchant_id"); + + b.HasIndex(new[] { "CreatedByRequestId" }, "uk_withdrawal_created_by_request_id") + .IsUnique() + .HasFilter("\"CreatedByRequestId\" IS NOT NULL"); + + b.ToTable("withdrawal_request", "settlement", t => + { + t.HasCheckConstraint("ck_withdrawal_amount_positive", "\"Amount\" > 0"); + + t.HasCheckConstraint("ck_withdrawal_status", "\"Status\" IN ('PENDING', 'APPROVED', 'REJECTED', 'PAID')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Shop.Domain.Shop", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .HasColumnType("varchar(100)"); + + b.Property("ContactPhone") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("MerchantId") + .HasColumnType("bigint"); + + b.Property("ShopDescription") + .HasColumnType("varchar(500)"); + + b.Property("ShopLogo") + .HasColumnType("varchar(500)"); + + b.Property("ShopName") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(32)") + .HasDefaultValue("ACTIVE"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "MerchantId" }, "ix_shop_merchant_id"); + + b.HasIndex(new[] { "MerchantId" }, "uk_shop_merchant_active") + .IsUnique() + .HasFilter("\"Status\" = 'ACTIVE'"); + + b.HasIndex(new[] { "ShopName" }, "uk_shop_name") + .IsUnique(); + + b.ToTable("shop", "shop", t => + { + t.HasCheckConstraint("ck_shop_status", "\"Status\" IN ('ACTIVE', 'SUSPENDED', 'CLOSED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Shop.Domain.ShopApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BusinessLicense") + .HasColumnType("varchar(500)"); + + b.Property("ContactEmail") + .HasColumnType("varchar(100)"); + + b.Property("ContactPhone") + .IsRequired() + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamptz"); + + b.Property("ReviewRemark") + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamptz"); + + b.Property("ReviewedBy") + .HasColumnType("bigint"); + + b.Property("ShopName") + .IsRequired() + .HasColumnType("varchar(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("varchar(32)") + .HasDefaultValue("PENDING"); + + b.Property("SubmittedAt") + .HasColumnType("timestamptz"); + + b.Property("UpdatedAt") + .HasColumnType("timestamptz"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.HasKey("Id"); + + b.HasIndex(new[] { "Status" }, "ix_shop_application_status"); + + b.HasIndex(new[] { "UserId" }, "ix_shop_application_user_id"); + + b.HasIndex(new[] { "UserId" }, "uk_shop_application_user_pending") + .IsUnique() + .HasFilter("\"Status\" = 'PENDING'"); + + b.ToTable("shop_application", "shop", t => + { + t.HasCheckConstraint("ck_shop_application_status", "\"Status\" IN ('PENDING', 'APPROVED', 'REJECTED')"); + }); + }); + + modelBuilder.Entity("EShop.Modules.Cart.Domain.CartItem", b => + { + b.HasOne("EShop.Modules.Cart.Domain.ShoppingCart", null) + .WithMany("Items") + .HasForeignKey("CartId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Cart.Domain.ShoppingCart", b => + { + b.HasOne("EShop.Modules.Identity.Domain.AppUser", null) + .WithOne() + .HasForeignKey("EShop.Modules.Cart.Domain.ShoppingCart", "UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_shopping_cart_user_id"); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.BrowseHistory", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.CatalogInventoryIdempotencyRequest", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.Category", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_category_parent_id"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.Product", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.Category", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.ProductFavorite", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.ProductImage", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.ProductReview", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.StockMovement", b => + { + b.HasOne("EShop.Modules.Catalog.Domain.CatalogInventoryIdempotencyRequest", null) + .WithMany() + .HasForeignKey("CreatedByRequestId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("EShop.Modules.Catalog.Domain.Product", null) + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Coupon.Domain.CouponClaim", b => + { + b.HasOne("EShop.Modules.Coupon.Domain.CouponTemplate", null) + .WithMany() + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Coupon.Domain.CouponRedemption", b => + { + b.HasOne("EShop.Modules.Coupon.Domain.CouponClaim", null) + .WithMany() + .HasForeignKey("ClaimId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.CustomerService.Domain.ConversationMessage", b => + { + b.HasOne("EShop.Modules.CustomerService.Domain.Conversation", null) + .WithMany() + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_conversation_message_conversation_id"); + + b.HasOne("EShop.Modules.CustomerService.Domain.CustomerServiceIdempotencyRequest", null) + .WithMany() + .HasForeignKey("CreatedByRequestId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_conversation_message_created_by_request_id"); + }); + + modelBuilder.Entity("EShop.Modules.Identity.Domain.AuthSession", b => + { + b.HasOne("EShop.Modules.Identity.Domain.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_auth_session_user_id"); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.SalesOrder", b => + { + b.HasOne("EShop.Modules.Ordering.Domain.OrderIdempotencyRequest", null) + .WithMany() + .HasForeignKey("CreatedByRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.SalesOrderItem", b => + { + b.HasOne("EShop.Modules.Ordering.Domain.SalesOrder", null) + .WithMany("Items") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Payment.Domain.PaymentRecord", b => + { + b.HasOne("EShop.Modules.Payment.Domain.PaymentIdempotencyRequest", null) + .WithMany() + .HasForeignKey("CreatedByRequestId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("EShop.Modules.Settlement.Domain.WithdrawalRequest", b => + { + b.HasOne("EShop.Modules.Settlement.Domain.SettlementIdempotencyRequest", null) + .WithMany() + .HasForeignKey("CreatedByRequestId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_withdrawal_created_by_request_id"); + }); + + modelBuilder.Entity("EShop.Modules.Cart.Domain.ShoppingCart", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("EShop.Modules.Catalog.Domain.Category", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("EShop.Modules.Ordering.Domain.SalesOrder", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.cs b/backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.cs new file mode 100644 index 0000000..1aeb97a --- /dev/null +++ b/backend/src/EShop.Infrastructure/Persistence/Migrations/20260810154915_AddCustomerServiceConversationGuards.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EShop.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddCustomerServiceConversationGuards : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + LOCK TABLE customer_service.conversation, customer_service.conversation_message + IN SHARE ROW EXCLUSIVE MODE; + + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM customer_service.conversation + WHERE "ContextType" = 'SHOP' AND "Status" = 'OPEN' + GROUP BY "CustomerUserId", "ShopId" + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'cannot add open SHOP conversation uniqueness: duplicate rows exist' + USING ERRCODE = '23514'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM customer_service.conversation + WHERE "ContextType" = 'PRODUCT' + AND "Status" = 'OPEN' + AND "ProductId" IS NOT NULL + GROUP BY "CustomerUserId", "ShopId", "ProductId" + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'cannot add open PRODUCT conversation uniqueness: duplicate rows exist' + USING ERRCODE = '23514'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM customer_service.conversation + WHERE "ContextType" = 'ORDER' + AND "Status" = 'OPEN' + AND "OrderId" IS NOT NULL + GROUP BY "CustomerUserId", "ShopId", "OrderId" + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION 'cannot add open ORDER conversation uniqueness: duplicate rows exist' + USING ERRCODE = '23514'; + END IF; + END $$; + """); + + migrationBuilder.AlterColumn( + name: "Content", + schema: "customer_service", + table: "conversation_message", + type: "varchar(2000)", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(1000)"); + + migrationBuilder.CreateIndex( + name: "uk_conversation_open_order_context", + schema: "customer_service", + table: "conversation", + columns: new[] { "CustomerUserId", "ShopId", "OrderId" }, + unique: true, + filter: "\"ContextType\" = 'ORDER' AND \"Status\" = 'OPEN' AND \"OrderId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "uk_conversation_open_product_context", + schema: "customer_service", + table: "conversation", + columns: new[] { "CustomerUserId", "ShopId", "ProductId" }, + unique: true, + filter: "\"ContextType\" = 'PRODUCT' AND \"Status\" = 'OPEN' AND \"ProductId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "uk_conversation_open_shop_context", + schema: "customer_service", + table: "conversation", + columns: new[] { "CustomerUserId", "ShopId" }, + unique: true, + filter: "\"ContextType\" = 'SHOP' AND \"Status\" = 'OPEN'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + LOCK TABLE customer_service.conversation_message IN SHARE ROW EXCLUSIVE MODE; + + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM customer_service.conversation_message + WHERE char_length("Content") > 1000 + ) THEN + RAISE EXCEPTION 'cannot narrow conversation message content: rows longer than 1000 characters exist' + USING ERRCODE = '23514'; + END IF; + END $$; + """); + + migrationBuilder.DropIndex( + name: "uk_conversation_open_order_context", + schema: "customer_service", + table: "conversation"); + + migrationBuilder.DropIndex( + name: "uk_conversation_open_product_context", + schema: "customer_service", + table: "conversation"); + + migrationBuilder.DropIndex( + name: "uk_conversation_open_shop_context", + schema: "customer_service", + table: "conversation"); + + migrationBuilder.AlterColumn( + name: "Content", + schema: "customer_service", + table: "conversation_message", + type: "varchar(1000)", + nullable: false, + oldClrType: typeof(string), + oldType: "varchar(2000)"); + } + } +} diff --git a/backend/src/EShop.Infrastructure/Persistence/Migrations/EShopDbContextModelSnapshot.cs b/backend/src/EShop.Infrastructure/Persistence/Migrations/EShopDbContextModelSnapshot.cs index a82c378..4bd3c50 100644 --- a/backend/src/EShop.Infrastructure/Persistence/Migrations/EShopDbContextModelSnapshot.cs +++ b/backend/src/EShop.Infrastructure/Persistence/Migrations/EShopDbContextModelSnapshot.cs @@ -1008,6 +1008,18 @@ namespace EShop.Infrastructure.Persistence.Migrations b.HasIndex(new[] { "Status" }, "ix_conversation_status"); + b.HasIndex(new[] { "CustomerUserId", "ShopId", "OrderId" }, "uk_conversation_open_order_context") + .IsUnique() + .HasFilter("\"ContextType\" = 'ORDER' AND \"Status\" = 'OPEN' AND \"OrderId\" IS NOT NULL"); + + b.HasIndex(new[] { "CustomerUserId", "ShopId", "ProductId" }, "uk_conversation_open_product_context") + .IsUnique() + .HasFilter("\"ContextType\" = 'PRODUCT' AND \"Status\" = 'OPEN' AND \"ProductId\" IS NOT NULL"); + + b.HasIndex(new[] { "CustomerUserId", "ShopId" }, "uk_conversation_open_shop_context") + .IsUnique() + .HasFilter("\"ContextType\" = 'SHOP' AND \"Status\" = 'OPEN'"); + b.ToTable("conversation", "customer_service", t => { t.HasCheckConstraint("ck_conversation_context_type", "\"ContextType\" IN ('SHOP', 'PRODUCT', 'ORDER')"); @@ -1026,7 +1038,7 @@ namespace EShop.Infrastructure.Persistence.Migrations b.Property("Content") .IsRequired() - .HasColumnType("varchar(1000)"); + .HasColumnType("varchar(2000)"); b.Property("ConversationId") .HasColumnType("bigint"); diff --git a/backend/src/EShop.Modules.CustomerService/Api/CustomerServiceEndpoints.cs b/backend/src/EShop.Modules.CustomerService/Api/CustomerServiceEndpoints.cs index 77c5738..6f9193e 100644 --- a/backend/src/EShop.Modules.CustomerService/Api/CustomerServiceEndpoints.cs +++ b/backend/src/EShop.Modules.CustomerService/Api/CustomerServiceEndpoints.cs @@ -114,11 +114,11 @@ public static class CustomerServiceEndpoints { var userId = GetUserId(httpContext); await useCase.ExecuteAsync(id, userId, "customer", httpContext.RequestAborted); - return TypedResults.NoContent(); + return TypedResults.Ok(null); }) .WithName("CustomerService_MarkRead") .WithDescription("标记会话中发给当前用户的消息为已读") - .Produces(StatusCodes.Status204NoContent) + .Produces>(StatusCodes.Status200OK, "application/json") .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound); @@ -220,11 +220,11 @@ public static class CustomerServiceEndpoints { var userId = GetUserId(httpContext); await useCase.ExecuteAsync(id, userId, "merchant", httpContext.RequestAborted); - return TypedResults.NoContent(); + return TypedResults.Ok(null); }) .WithName("CustomerService_MerchantMarkRead") .WithDescription("商家标记消息已读") - .Produces(StatusCodes.Status204NoContent) + .Produces>(StatusCodes.Status200OK, "application/json") .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound); @@ -325,11 +325,11 @@ public static class CustomerServiceEndpoints { var userId = GetUserId(httpContext); await useCase.ExecuteAsync(id, userId, SenderRole.Admin, httpContext.RequestAborted); - return TypedResults.NoContent(); + return TypedResults.Ok(null); }) .WithName("CustomerService_AdminMarkRead") .WithDescription("超管标记已介入会话中的消息为已读") - .Produces(StatusCodes.Status204NoContent) + .Produces>(StatusCodes.Status200OK, "application/json") .ProducesProblem(StatusCodes.Status401Unauthorized) .ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status404NotFound); diff --git a/backend/src/EShop.Modules.CustomerService/Application/Dtos/CustomerServiceDtos.cs b/backend/src/EShop.Modules.CustomerService/Application/Dtos/CustomerServiceDtos.cs index 0025263..e6b9f3f 100644 --- a/backend/src/EShop.Modules.CustomerService/Application/Dtos/CustomerServiceDtos.cs +++ b/backend/src/EShop.Modules.CustomerService/Application/Dtos/CustomerServiceDtos.cs @@ -6,9 +6,9 @@ namespace EShop.Modules.CustomerService.Application.Dtos; public sealed record CreateConversationRequest( string ContextType, - long? ShopId = null, - long? ProductId = null, - long? OrderId = null, + string? ShopId = null, + string? ProductId = null, + string? OrderId = null, string? ProductName = null); public sealed record SendMessageRequest(string Content); diff --git a/backend/src/EShop.Modules.CustomerService/Application/UseCases/CustomerServiceUseCases.cs b/backend/src/EShop.Modules.CustomerService/Application/UseCases/CustomerServiceUseCases.cs index 7bd3d41..d1dd91e 100644 --- a/backend/src/EShop.Modules.CustomerService/Application/UseCases/CustomerServiceUseCases.cs +++ b/backend/src/EShop.Modules.CustomerService/Application/UseCases/CustomerServiceUseCases.cs @@ -42,9 +42,10 @@ public class CreateConversationUseCase : ICreateConversationUseCase long merchantId; long shopId; - long? orderId = request.OrderId; - long? productId = request.ProductId; - string? productName = request.ProductName; + long? orderId = ParseOptionalPositiveId(request.OrderId, "OrderId"); + long? productId = ParseOptionalPositiveId(request.ProductId, "ProductId"); + var requestedShopId = ParseOptionalPositiveId(request.ShopId, "ShopId"); + string? productName = null; switch (request.ContextType) { @@ -63,6 +64,7 @@ public class CreateConversationUseCase : ICreateConversationUseCase throw new DomainException("ORDER_STATE_CONFLICT", "该订单无商家信息,无法创建客服会话"); merchantId = order.MerchantId; + productId = null; break; case ConversationContextType.Product: @@ -80,15 +82,16 @@ public class CreateConversationUseCase : ICreateConversationUseCase throw new DomainException("PRODUCT_OWNER_INVALID", "该商品没有关联商家"); merchantId = product.MerchantId; - productName ??= product.ProductName; + productName = product.ProductName; + orderId = null; break; case ConversationContextType.Shop: // 校验店铺存在且 ACTIVE - if (request.ShopId is null || request.ShopId <= 0) + if (requestedShopId is null) throw new DomainException("COMMON_VALIDATION_FAILED", "SHOP 上下文必须提供 ShopId"); - var shop = await _contextPort.FindShopAsync(request.ShopId.Value, ct) + var shop = await _contextPort.FindShopAsync(requestedShopId.Value, ct) ?? throw new DomainException("RESOURCE_NOT_FOUND", $"店铺 {request.ShopId} 不存在"); if (!shop.IsActive) @@ -96,6 +99,8 @@ public class CreateConversationUseCase : ICreateConversationUseCase merchantId = shop.MerchantId; shopId = shop.ShopId; + orderId = null; + productId = null; goto CheckDuplicate; default: @@ -125,11 +130,42 @@ public class CreateConversationUseCase : ICreateConversationUseCase userId, merchantId, shopId, request.ContextType, orderId, productId, productName); _db.Set().Add(conversation); - await _db.SaveChangesAsync(ct); + try + { + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + _db.Entry(conversation).State = EntityState.Detached; + var winner = await FindExistingOpenConversation( + userId, request.ContextType, shopId, orderId, productId, ct); + if (winner is not null) + return MapToResponse(winner); + throw; + } return MapToResponse(conversation); } + private static long? ParseOptionalPositiveId(string? raw, string label) + { + if (raw is null) + return null; + + if (raw.Length == 0 + || raw[0] == '0' + || raw.Any(character => character is < '0' or > '9') + || !long.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + || parsed <= 0) + { + throw new DomainException( + "COMMON_VALIDATION_FAILED", + $"{label} 必须为正十进制整数字符串。"); + } + + return parsed; + } + private async Task FindExistingOpenConversation( long userId, string contextType, long shopId, long? orderId, long? productId, CancellationToken ct) @@ -147,7 +183,7 @@ public class CreateConversationUseCase : ICreateConversationUseCase _ => query }; - return await query.FirstOrDefaultAsync(ct); + return await query.AsNoTracking().OrderBy(c => c.Id).FirstOrDefaultAsync(ct); } private static ConversationResponse MapToResponse(Conversation c) => new( @@ -454,8 +490,8 @@ public class SendMessageUseCase : ISendMessageUseCase throw new DomainException("COMMON_VALIDATION_FAILED", "消息内容不能为空"); var normalizedContent = content.Trim(); - if (normalizedContent.Length > 1000) - throw new DomainException("COMMON_VALIDATION_FAILED", "消息内容不能超过 1000 个字符"); + if (normalizedContent.Length > 2000) + throw new DomainException("COMMON_VALIDATION_FAILED", "消息内容不能超过 2000 个字符"); var canonicalHash = CustomerServiceIdempotencyHelpers.HashCanonicalRequest( JsonSerializer.Serialize(new diff --git a/backend/src/EShop.Modules.CustomerService/Domain/Conversation.cs b/backend/src/EShop.Modules.CustomerService/Domain/Conversation.cs index 553db6a..ce5d60b 100644 --- a/backend/src/EShop.Modules.CustomerService/Domain/Conversation.cs +++ b/backend/src/EShop.Modules.CustomerService/Domain/Conversation.cs @@ -70,6 +70,7 @@ public sealed class Conversation : AggregateRoot PlatformIntervened = true; PlatformIntervenedAt = DateTime.UtcNow; + LastMessageAt = PlatformIntervenedAt.Value; IncrementVersion(); } diff --git a/backend/src/EShop.Modules.CustomerService/Domain/ConversationMessage.cs b/backend/src/EShop.Modules.CustomerService/Domain/ConversationMessage.cs index 4aac5fd..af82cf1 100644 --- a/backend/src/EShop.Modules.CustomerService/Domain/ConversationMessage.cs +++ b/backend/src/EShop.Modules.CustomerService/Domain/ConversationMessage.cs @@ -22,8 +22,8 @@ public sealed class ConversationMessage : AuditableEntity ArgumentException.ThrowIfNullOrWhiteSpace(senderRole); ArgumentException.ThrowIfNullOrWhiteSpace(content); - if (content.Length > 1000) - throw new DomainException("COMMON_VALIDATION_FAILED", "消息内容不能超过 1000 个字符"); + if (content.Length > 2000) + throw new DomainException("COMMON_VALIDATION_FAILED", "消息内容不能超过 2000 个字符"); return new ConversationMessage { diff --git a/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationConfiguration.cs b/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationConfiguration.cs index bbc814e..bdd2ad1 100644 --- a/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationConfiguration.cs +++ b/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationConfiguration.cs @@ -41,6 +41,24 @@ public sealed class ConversationConfiguration : IEntityTypeConfiguration x.Status, "ix_conversation_status"); + builder.HasIndex( + x => new { x.CustomerUserId, x.ShopId }, + "uk_conversation_open_shop_context") + .IsUnique() + .HasFilter("\"ContextType\" = 'SHOP' AND \"Status\" = 'OPEN'"); + builder.HasIndex( + x => new { x.CustomerUserId, x.ShopId, x.ProductId }, + "uk_conversation_open_product_context") + .IsUnique() + .HasFilter( + "\"ContextType\" = 'PRODUCT' AND \"Status\" = 'OPEN' AND \"ProductId\" IS NOT NULL"); + builder.HasIndex( + x => new { x.CustomerUserId, x.ShopId, x.OrderId }, + "uk_conversation_open_order_context") + .IsUnique() + .HasFilter( + "\"ContextType\" = 'ORDER' AND \"Status\" = 'OPEN' AND \"OrderId\" IS NOT NULL"); + builder.Property(x => x.LastMessageAt).HasColumnType("timestamptz").IsRequired(); builder.Property(x => x.CreatedAt).HasColumnType("timestamptz").IsRequired(); diff --git a/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationMessageConfiguration.cs b/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationMessageConfiguration.cs index 5b80d1f..602b8dc 100644 --- a/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationMessageConfiguration.cs +++ b/backend/src/EShop.Modules.CustomerService/Infrastructure/ConversationMessageConfiguration.cs @@ -29,7 +29,7 @@ public sealed class ConversationMessageConfiguration : IEntityTypeConfiguration< "ck_conversation_message_sender_role", "\"SenderRole\" IN ('customer', 'merchant', 'admin')")); - builder.Property(x => x.Content).HasColumnType("varchar(1000)").IsRequired(); + builder.Property(x => x.Content).HasColumnType("varchar(2000)").IsRequired(); builder.Property(x => x.IsRead).HasColumnType("boolean").IsRequired().HasDefaultValue(false); builder.Property(x => x.CreatedByRequestId).HasColumnType("bigint").IsRequired(false); diff --git a/backend/tests/EShop.Tests.Architecture/BackendStructureTests.cs b/backend/tests/EShop.Tests.Architecture/BackendStructureTests.cs index 891f427..f6ed822 100644 --- a/backend/tests/EShop.Tests.Architecture/BackendStructureTests.cs +++ b/backend/tests/EShop.Tests.Architecture/BackendStructureTests.cs @@ -360,6 +360,8 @@ public sealed class BackendStructureTests "20260806010327_AddCouponRuleEngine.Designer.cs", "20260809063404_AddSettlementIdempotencyAndShopConcurrencyGuards.cs", "20260809063404_AddSettlementIdempotencyAndShopConcurrencyGuards.Designer.cs", + "20260810154915_AddCustomerServiceConversationGuards.cs", + "20260810154915_AddCustomerServiceConversationGuards.Designer.cs", "EShopDbContextModelSnapshot.cs", }.OrderBy(path => path, StringComparer.Ordinal); @@ -498,7 +500,10 @@ public sealed class BackendStructureTests .Where(element => element.Name.LocalName == "ProjectReference") .Select(element => element.Attribute("Include")?.Value) .Where(path => !string.IsNullOrWhiteSpace(path)) - .Select(path => Path.GetFileNameWithoutExtension(path!)) + .Select(path => path! + .Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar)) + .Select(path => Path.GetFileNameWithoutExtension(path)!) .ToArray(); } diff --git a/backend/tests/EShop.Tests.Unit/Modules/CustomerService/CreateConversationUseCaseTests.cs b/backend/tests/EShop.Tests.Unit/Modules/CustomerService/CreateConversationUseCaseTests.cs index 0f70708..319d3fd 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/CustomerService/CreateConversationUseCaseTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/CustomerService/CreateConversationUseCaseTests.cs @@ -22,7 +22,8 @@ public sealed class CreateConversationUseCaseTests var sut = new CreateConversationUseCase(db, contextPort); var request = new CreateConversationRequest( ConversationContextType.Product, - ProductId: 44); + ProductId: "44", + ProductName: "不可信的客户端商品名"); var first = await sut.ExecuteAsync(11, request); var replay = await sut.ExecuteAsync(11, request); @@ -49,11 +50,29 @@ public sealed class CreateConversationUseCaseTests 11, new CreateConversationRequest( ConversationContextType.Order, - OrderId: 66))); + OrderId: "66"))); Assert.Equal("ORDER_PERMISSION_DENIED", exception.Code); } + [Theory] + [InlineData("")] + [InlineData("0")] + [InlineData("-1")] + [InlineData(" 44 ")] + [InlineData("9223372036854775808")] + public async Task Product_context_rejects_non_canonical_string_ids(string productId) + { + await using var db = CreateDbContext(); + var sut = new CreateConversationUseCase(db, new StubContextPort()); + + var exception = await Assert.ThrowsAsync(() => sut.ExecuteAsync( + 11, + new CreateConversationRequest(ConversationContextType.Product, ProductId: productId))); + + Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); + } + private static CustomerServiceTestDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() -- Gitee From f6784aebfe83378f9e00c4565d1984560e913f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:50:39 +0800 Subject: [PATCH 04/33] feat(api): harden catalog seed and timeout coordination --- .../PostgresOrderTimeoutLock.cs | 25 +- backend/src/EShop.Api/Program.cs | 17 +- .../EShop.Api/Seeding/CatalogSeedCommand.cs | 32 +- backend/src/EShop.Api/appsettings.json | 2 +- .../Application/Seeding/CatalogDataSeeder.cs | 596 +- .../Seeding/CatalogDemoProductCatalog.cs | 750 ++ .../Seeding/CatalogSeedCoverPng.cs | 227 + .../Seeding/CatalogSeedMaterialLoader.cs | 721 ++ .../Modules/Catalog/CatalogDataSeederTests.cs | 349 +- frontend/package.json | 6 +- scripts/database/catalog-schema.sql | 2 +- scripts/seed/.gitignore | 11 + scripts/seed/create-runtime-artifact.mjs | 217 + scripts/seed/mi-catalog.json | 9963 +++++++++++++++++ 14 files changed, 12734 insertions(+), 184 deletions(-) create mode 100644 backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDemoProductCatalog.cs create mode 100644 backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedCoverPng.cs create mode 100644 backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs create mode 100644 scripts/seed/.gitignore create mode 100644 scripts/seed/create-runtime-artifact.mjs create mode 100644 scripts/seed/mi-catalog.json diff --git a/backend/src/EShop.Api/BackgroundServices/PostgresOrderTimeoutLock.cs b/backend/src/EShop.Api/BackgroundServices/PostgresOrderTimeoutLock.cs index ee65c68..4dcbf94 100644 --- a/backend/src/EShop.Api/BackgroundServices/PostgresOrderTimeoutLock.cs +++ b/backend/src/EShop.Api/BackgroundServices/PostgresOrderTimeoutLock.cs @@ -41,12 +41,17 @@ public sealed class PostgresOrderTimeoutLock : IOrderTimeoutDistributedLock await connection.OpenAsync(ct); // PostgreSQL 18.4 支持 hashtextextended;锁键为编译期常量,无注入风险 - var acquired = await db.Database - .ExecuteSqlRawAsync( - "SELECT pg_try_advisory_lock(hashtextextended('eshop:order-timeout', 0))", - ct); + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT pg_try_advisory_lock(hashtextextended(@lock_key, 0))"; + var lockKeyParameter = command.CreateParameter(); + lockKeyParameter.ParameterName = "lock_key"; + lockKeyParameter.Value = _lockKey; + command.Parameters.Add(lockKeyParameter); + var scalar = await command.ExecuteScalarAsync(ct); + var acquired = scalar is true; - if (acquired == 0) + if (!acquired) { _logger.LogInformation( "订单超时扫描本轮跳过:另一副本正在执行扫描(PostgreSQL 咨询锁未获取)"); @@ -97,10 +102,14 @@ public sealed class PostgresOrderTimeoutLock : IOrderTimeoutDistributedLock // 显式释放锁(连接关闭也会兜底释放,但主动释放更清晰) if (_connection.State == System.Data.ConnectionState.Open) { - using var cmd = _connection.CreateCommand(); + await using var cmd = _connection.CreateCommand(); cmd.CommandText = - $"SELECT pg_advisory_unlock(hashtextextended('{_lockKey}', 0))"; - await cmd.ExecuteNonQueryAsync(); + "SELECT pg_advisory_unlock(hashtextextended(@lock_key, 0))"; + var lockKeyParameter = cmd.CreateParameter(); + lockKeyParameter.ParameterName = "lock_key"; + lockKeyParameter.Value = _lockKey; + cmd.Parameters.Add(lockKeyParameter); + _ = await cmd.ExecuteScalarAsync(); await _connection.CloseAsync(); } } diff --git a/backend/src/EShop.Api/Program.cs b/backend/src/EShop.Api/Program.cs index 9b634a5..f01e304 100644 --- a/backend/src/EShop.Api/Program.cs +++ b/backend/src/EShop.Api/Program.cs @@ -56,12 +56,23 @@ builder.Services.AddSingleton(); // 业务 UseCase + 跨模块 Port 注册(与集成测试宿主共用一份清单) // ================================================================ builder.Services.AddEShopUseCases(); -builder.Services.AddScoped(); +builder.Services.AddScoped(serviceProvider => +{ + var db = serviceProvider.GetRequiredService(); + var env = serviceProvider.GetRequiredService(); + var configuration = serviceProvider.GetRequiredService(); + return new CatalogDataSeeder( + db, + env.WebRootPath, + env.ContentRootPath, + configuration["CatalogSeed:MaterialRoot"], + requireMaterial: true, + requireReviewedArtifact: true); +}); builder.Services.AddScoped(); // C03 订单超时自动取消:每 60 秒扫描 PENDING_PAYMENT 超过 30 分钟的订单 -// 多实例部署时替换为 PostgresOrderTimeoutLock 以启用 PostgreSQL 咨询锁 -builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.Configure( diff --git a/backend/src/EShop.Api/Seeding/CatalogSeedCommand.cs b/backend/src/EShop.Api/Seeding/CatalogSeedCommand.cs index 9c51f13..b81bb92 100644 --- a/backend/src/EShop.Api/Seeding/CatalogSeedCommand.cs +++ b/backend/src/EShop.Api/Seeding/CatalogSeedCommand.cs @@ -90,12 +90,34 @@ public static class CatalogSeedCommand .GetPendingMigrationsAsync(cancellationToken)) .ToArray(); EnsureNoPendingMigrations(pendingMigrations); + seeder.ValidateMaterial(); - await using var transaction = await db.Database.BeginTransactionAsync( - IsolationLevel.Serializable, - cancellationToken); - await seeder.SeedAsync(cancellationToken); - await transaction.CommitAsync(cancellationToken); + var commitAttempted = false; + try + { + await using var transaction = await db.Database.BeginTransactionAsync( + IsolationLevel.Serializable, + cancellationToken); + await seeder.SeedAsync(cancellationToken); + commitAttempted = true; + await transaction.CommitAsync(CancellationToken.None); + seeder.MarkFileWritesCommitted(); + } + catch + { + if (!commitAttempted) + { + await seeder.CleanupUncommittedFilesAsync(); + } + else + { + logger.LogError( + "Catalog Seed 提交结果未知;为避免已提交 product_image 指向缺失文件," + + "已保留新写文件,必须人工核对数据库与文件清单后再处理。"); + } + + throw; + } logger.LogInformation( "Catalog Seed 已成功提交。进程将正常退出,不启动 HTTP API 或后台任务。"); diff --git a/backend/src/EShop.Api/appsettings.json b/backend/src/EShop.Api/appsettings.json index 368c57a..2d81dd0 100644 --- a/backend/src/EShop.Api/appsettings.json +++ b/backend/src/EShop.Api/appsettings.json @@ -3,7 +3,7 @@ "DefaultConnection": "Host=127.0.0.1;Port=1;Database=eshop_unconfigured;Username=eshop;Password=not-a-secret;Timeout=1;Command Timeout=1;Pooling=false" }, "CatalogSeed": { - "Enabled": true + "Enabled": false }, "CatalogMerchantSeed": { "Enabled": true diff --git a/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDataSeeder.cs b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDataSeeder.cs index f49075b..53b5090 100644 --- a/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDataSeeder.cs +++ b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDataSeeder.cs @@ -1,50 +1,122 @@ +using System.Security.Cryptography; +using System.Text; using EShop.Modules.Catalog.Domain; using Microsoft.EntityFrameworkCore; namespace EShop.Modules.Catalog.Application.Seeding; /// -/// Catalog 演示种子数据 —— 6 个顶级类目 + 多级子类目 / 30 个商品。 -/// Schema: catalog, Table: category / product +/// Catalog 演示种子数据 —— 多级类目 + 商品 + 可选本地封面。 +/// Schema: catalog, Table: category / product / product_image /// -/// 幂等键(重复执行只补缺,不覆盖已有数据): -/// - 分类:Category.Normalize(Name)(未删除范围内查重,存在则复用其 Id); -/// - 商品:Sku(Sku 唯一索引不过滤软删除,查重同样不过滤)。 -/// -/// 本类不自行迁移数据库,也不自行触发。唯一入口是 API 宿主显式 -/// --seed-catalog 命令;宿主负责配置双门、Migration 检查和关系库事务。 +/// 幂等键:分类 NormalizedName;商品 Sku;封面已有 IsCover 则跳过。 +/// 若仓库存在 scripts/seed/mi-catalog.json 与 media/{sku}/*,会优先使用小米公开接口整理的 +/// 名称/价格/参数页/多图详情;缺失时回退到内置演示数据与生成 PNG。 /// public sealed class CatalogDataSeeder { + private const int MaxImagesPerProduct = CatalogSeedMaterialLoader.MaxImagesPerProduct; + private const int MaxSeedImageBytes = CatalogSeedMaterialLoader.MaxImageBytes; + private const long MaxSeedArtifactBytes = CatalogSeedMaterialLoader.MaxArtifactBytes; + private readonly DbContext _db; + private readonly string? _webRootPath; + private readonly string? _seedDirectory; + private readonly bool _requireMaterial; + private readonly bool _requireReviewedArtifact; + private IReadOnlyList? _validatedMiProducts; + private readonly HashSet _uncommittedFiles = new( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); public CatalogDataSeeder(DbContext db) + : this(db, webRootPath: null, contentRootPath: null, configuredMaterialRoot: null, requireMaterial: false) + { + } + + /// Catalog 所用的 EF DbContext。 + /// API wwwroot;非空时写入封面文件。 + public CatalogDataSeeder(DbContext db, string? webRootPath) + : this(db, webRootPath, contentRootPath: null, configuredMaterialRoot: null, requireMaterial: false) + { + } + + /// Catalog 所用的 EF DbContext。 + /// API wwwroot;非空时写入封面文件。 + /// API ContentRoot,用于定位仓库 scripts/seed 素材。 + /// 显式配置的、已审核 Seed 制品根目录。 + /// 是否在素材缺失或损坏时拒绝执行。 + /// 是否强制校验 ARTIFACT.json 与 SHA256SUMS。 + public CatalogDataSeeder( + DbContext db, + string? webRootPath, + string? contentRootPath, + string? configuredMaterialRoot = null, + bool requireMaterial = false, + bool requireReviewedArtifact = false) { _db = db; + _webRootPath = string.IsNullOrWhiteSpace(webRootPath) ? null : webRootPath; + _seedDirectory = CatalogSeedMaterialLoader.ResolveSeedDirectory( + contentRootPath, + configuredMaterialRoot); + _requireMaterial = requireMaterial; + _requireReviewedArtifact = requireReviewedArtifact; } - /// 分两阶段补齐:分类(先落库拿 Id)→ 商品。 public async Task SeedAsync(CancellationToken ct = default) { - var categoryIdByCode = await EnsureCategoriesAsync(ct); - await EnsureProductsAsync(categoryIdByCode, ct); + try + { + var categoryIdByCode = await EnsureCategoriesAsync(ct); + var productSeeds = BuildMergedProductSeeds(); + await EnsureProductsAsync(categoryIdByCode, productSeeds, ct); + await EnsureCoverImagesAsync(productSeeds, ct); + } + catch + { + await CleanupUncommittedFilesAsync(); + throw; + } } - // ============================================================ - // 阶段一:分类(多轮插入:顶级 → 子级 → 孙级) - // ============================================================ + public void MarkFileWritesCommitted() => _uncommittedFiles.Clear(); /// - /// 确保分类存在(按 NormalizedName 在未删除范围内查重)。 - /// 多轮插入保证父类目先落库拿到 ID,子类目随后引用。 - /// 返回 类别码 → 分类 Id。 + /// 在打开数据库写事务前完整验证受审素材制品并缓存解析结果,避免把近 1 GB 哈希 I/O + /// 放在 Serializable 事务和业务锁内。 /// + public void ValidateMaterial() + { + _validatedMiProducts = CatalogSeedMaterialLoader.LoadMiProducts( + _seedDirectory, + _requireMaterial, + _requireReviewedArtifact); + } + + public Task CleanupUncommittedFilesAsync() + { + foreach (var path in _uncommittedFiles) + { + try + { + File.Delete(path); + } + catch (FileNotFoundException) + { + // Already absent; cleanup remains idempotent. + } + } + + _uncommittedFiles.Clear(); + return Task.CompletedTask; + } + private async Task> EnsureCategoriesAsync(CancellationToken ct) { var categoryIds = new Dictionary(); + var categorySeeds = CatalogDemoProductCatalog.Categories; - // 先读库中已有 ID(幂等:之前执行过则直接复用) - foreach (var seed in CategorySeeds) + foreach (var seed in categorySeeds) { var normalized = Category.Normalize(seed.Name); var id = await _db.Set() @@ -53,25 +125,27 @@ public sealed class CatalogDataSeeder .Select(c => c.Id) .FirstOrDefaultAsync(ct); if (id != 0) + { categoryIds[seed.Code] = id; + } } - // 逐层插入:每轮只插入父级 ID 已知的种子,SaveChanges 后回读新 ID,直到全部补齐 - var remaining = CategorySeeds.Where(s => !categoryIds.ContainsKey(s.Code)).ToList(); + var remaining = categorySeeds.Where(s => !categoryIds.ContainsKey(s.Code)).ToList(); while (remaining.Count > 0) { - // 快照本轮开始时的已知 ID,用于统一判断插入资格 var knownIds = new HashSet(categoryIds.Keys); - var insertedThisRound = new List(); + var insertedThisRound = new List(); foreach (var seed in remaining) { - // 父级必须在本轮开始前已就绪 long? parentId = null; if (seed.ParentCode != null) { if (!knownIds.Contains(seed.ParentCode)) - continue; // 父级尚未入库,下轮再试 + { + continue; + } + parentId = categoryIds[seed.ParentCode]; } @@ -81,7 +155,6 @@ public sealed class CatalogDataSeeder if (insertedThisRound.Count == 0) { - // 还有剩余但无法推进(数据有问题,如 ParentCode 指向不存在的 Code) var missingParents = remaining .Where(s => s.ParentCode != null && !knownIds.Contains(s.ParentCode)) .Select(s => $"{s.Code}(parent={s.ParentCode})"); @@ -91,7 +164,6 @@ public sealed class CatalogDataSeeder await _db.SaveChangesAsync(ct); - // 回读本轮新插入的 ID foreach (var seed in insertedThisRound) { var normalized = Category.Normalize(seed.Name); @@ -103,20 +175,80 @@ public sealed class CatalogDataSeeder categoryIds[seed.Code] = id; } - remaining = CategorySeeds.Where(s => !categoryIds.ContainsKey(s.Code)).ToList(); + remaining = categorySeeds.Where(s => !categoryIds.ContainsKey(s.Code)).ToList(); } return categoryIds; } - // ============================================================ - // 阶段二:商品 - // ============================================================ + /// + /// 合并内置种子与 mi-catalog.json:同 SKU 以小米公开数据覆盖名称/价/品牌/规格; + /// 仅存在于 mi-catalog 的 SKU 追加为新商品。 + /// + private List BuildMergedProductSeeds() + { + var bySku = CatalogDemoProductCatalog.Products + .ToDictionary(p => p.Sku, p => p, StringComparer.Ordinal); + + var miProducts = _validatedMiProducts + ?? CatalogSeedMaterialLoader.LoadMiProducts( + _seedDirectory, + _requireMaterial, + _requireReviewedArtifact); + foreach (var mi in miProducts) + { + var categoryCode = bySku.TryGetValue(mi.Sku, out var existing) + ? existing.CategoryCode + : CatalogSeedMaterialLoader.InferCategoryCode(mi.Sku); + + var stock = existing?.StockQuantity + ?? (mi.Sku.Contains("DIG-007", StringComparison.Ordinal) ? 2 + : mi.Sku.Contains("DIG-008", StringComparison.Ordinal) ? 0 + : 120); + + var status = existing?.Status + ?? (mi.Sku is "SKU-DIG-009" or "SKU-HOM-009" + ? ProductStatus.Inactive + : ProductStatus.Active); + + var name = Truncate( + string.IsNullOrWhiteSpace(mi.Name) ? existing?.Name ?? mi.Sku : mi.Name, + 100); + var description = string.IsNullOrWhiteSpace(mi.Description) + ? existing?.Description + : Truncate(mi.Description!, 2000); + var specsJson = CatalogSeedMaterialLoader.SpecsToJson(mi); + var sales = mi.SalesCount > 0 ? mi.SalesCount : existing?.SalesCount ?? 10_000; + var price = mi.UnitPrice > 0 ? mi.UnitPrice : existing?.UnitPrice ?? 99m; + var brand = string.IsNullOrWhiteSpace(mi.Brand) ? existing?.Brand : mi.Brand; + + bySku[mi.Sku] = new CatalogDemoProductCatalog.ProductSeed( + mi.Sku, + name, + categoryCode, + price, + stock, + description, + status, + brand, + sales, + specsJson, + existing?.CoverHue ?? StableCoverHue(mi.Sku), + mi.CoverFile, + mi); + } + + return bySku.Values + .OrderBy(p => p.Sku, StringComparer.Ordinal) + .ToList(); + } - /// 按 Sku 补齐缺失商品;Sku 唯一索引含软删除行,查重故意不加 DeletedAt 条件。 - private async Task EnsureProductsAsync(IReadOnlyDictionary categoryIdByCode, CancellationToken ct) + private async Task EnsureProductsAsync( + IReadOnlyDictionary categoryIdByCode, + IReadOnlyList productSeeds, + CancellationToken ct) { - var seedSkus = ProductSeeds.Select(p => p.Sku).ToList(); + var seedSkus = productSeeds.Select(p => p.Sku).ToList(); var existingSkus = await _db.Set() .AsNoTracking() .Where(p => seedSkus.Contains(p.Sku)) @@ -125,24 +257,30 @@ public sealed class CatalogDataSeeder var existingSkuSet = existingSkus.ToHashSet(StringComparer.Ordinal); var added = false; - foreach (var seed in ProductSeeds) + foreach (var seed in productSeeds) { if (existingSkuSet.Contains(seed.Sku)) { - continue; // 幂等:已存在(含软删除)则跳过,不覆盖人工修改 + continue; + } + + if (!categoryIdByCode.TryGetValue(seed.CategoryCode, out var categoryId)) + { + throw new InvalidOperationException( + $"商品 {seed.Sku} 引用了不存在的分类 Code:{seed.CategoryCode}"); } var product = Product.Create( seed.Sku, seed.Name, - categoryIdByCode[seed.CategoryCode], + categoryId, seed.UnitPrice, seed.StockQuantity, seed.Description, seed.Status, - brand: null, - salesCount: 0, - specificationsJson: null); + brand: seed.Brand, + salesCount: seed.SalesCount, + specificationsJson: seed.SpecificationsJson); _db.Set().Add(product); added = true; } @@ -153,123 +291,265 @@ public sealed class CatalogDataSeeder } } - // ============================================================ - // 种子数据定义 - // ============================================================ - - /// 分类种子;Code 仅用于本文件内关联,不入库。ParentCode 为 null 表示顶级。 - private sealed record CategorySeed(string Code, string Name, int SortOrder, string? ParentCode); - - /// 商品种子;Status 直接使用 ProductStatus 常量。 - private sealed record ProductSeed( - string Sku, - string Name, - string CategoryCode, - decimal UnitPrice, - int StockQuantity, - string? Description, - string Status); - - private static readonly CategorySeed[] CategorySeeds = - [ - // ===== 顶级类目 ===== - new("DIG", "手机数码", 1, null), - new("HOM", "家居生活", 2, null), - new("BOK", "图书文创", 3, null), - new("CLO", "服饰鞋包", 4, null), - new("FOO", "食品生鲜", 5, null), - new("BEA", "美妆个护", 6, null), - - // ===== 手机数码的子级 ===== - new("DIG-PHONE", "手机", 1, "DIG"), - new("DIG-PAD", "平板电脑", 2, "DIG"), - new("DIG-WEAR", "智能穿戴", 3, "DIG"), - new("DIG-ACC", "手机配件", 4, "DIG"), - - // ===== 手机的子级(三级类目) ===== - new("DIG-PHONE-SMART", "智能手机", 1, "DIG-PHONE"), - new("DIG-PHONE-FEATURE", "功能机", 2, "DIG-PHONE"), - - // ===== 平板电脑的子级 ===== - new("DIG-PAD-IPAD", "iPad", 1, "DIG-PAD"), - new("DIG-PAD-ANDROID", "安卓平板", 2, "DIG-PAD"), - - // ===== 智能穿戴的子级 ===== - new("DIG-WEAR-WATCH", "智能手表", 1, "DIG-WEAR"), - new("DIG-WEAR-BAND", "智能手环", 2, "DIG-WEAR"), - - // ===== 手机配件的子级 ===== - new("DIG-ACC-CASE", "手机壳", 1, "DIG-ACC"), - new("DIG-ACC-CHARGER", "充电器", 2, "DIG-ACC"), - new("DIG-ACC-CABLE", "数据线", 3, "DIG-ACC"), - - // ===== 家居生活的子级 ===== - new("HOM-BED", "家纺", 1, "HOM"), - new("HOM-KITCHEN", "厨具", 2, "HOM"), - new("HOM-STORAGE", "收纳整理", 3, "HOM"), - new("HOM-DECOR", "家居饰品", 4, "HOM"), - - // ===== 图书文创的子级 ===== - new("BOK-BOOK", "图书", 1, "BOK"), - new("BOK-STATIONERY", "文具", 2, "BOK"), - new("BOK-POSTER", "海报文创", 3, "BOK"), - - // ===== 服饰鞋包的子级 ===== - new("CLO-MEN", "男装", 1, "CLO"), - new("CLO-WOMEN", "女装", 2, "CLO"), - new("CLO-SHOES", "鞋类", 3, "CLO"), - new("CLO-BAG", "箱包", 4, "CLO"), - - // ===== 食品生鲜的子级 ===== - new("FOO-SNACK", "休闲零食", 1, "FOO"), - new("FOO-DRINK", "饮料冲调", 2, "FOO"), - new("FOO-FRESH", "生鲜水果", 3, "FOO"), - - // ===== 美妆个护的子级 ===== - new("BEA-SKIN", "护肤", 1, "BEA"), - new("BEA-MAKEUP", "彩妆", 2, "BEA"), - new("BEA-HAIR", "洗护发", 3, "BEA"), - ]; + private async Task EnsureCoverImagesAsync( + IReadOnlyList productSeeds, + CancellationToken ct) + { + if (_webRootPath is null) + { + if (_requireMaterial) + { + throw new InvalidOperationException("Catalog Seed 需要可写的 WebRootPath 才能导入商品图片。"); + } - /// - /// 30 个商品,分属不同子类目。库存特例:SKU-DIG-007 低库存(2)、SKU-DIG-008 零库存上架; - /// 下架特例:SKU-DIG-009、SKU-HOM-009;SKU-HOM-001 名称含 "%"(验证 F05 字面搜索)。 - /// 商品关联到末级类目(如"智能手机"而非"手机数码")。 - /// - private static readonly ProductSeed[] ProductSeeds = - [ - // ---- 手机数码 → 手机配件(充电器/数据线/线材) ---- - new("SKU-DIG-001", "Type-C 快充数据线 1.5m", "DIG-ACC-CABLE", 19.90m, 500, "支持 PD 100W 快充,尼龙编织线身,兼容主流安卓与 iPad。", ProductStatus.Active), - new("SKU-DIG-002", "65W GaN 氮化镓充电器", "DIG-ACC-CHARGER", 129.00m, 320, "单口 65W / 双口智能分配,兼容 PD、QC 快充协议。", ProductStatus.Active), - new("SKU-DIG-003", "真无线蓝牙耳机 Buds Pro", "DIG-WEAR-WATCH", 399.00m, 150, "主动降噪,综合续航 30 小时,支持无线充电。", ProductStatus.Active), - new("SKU-DIG-004", "便携蓝牙音箱 SoundGo Mini", "DIG-WEAR-BAND", 199.00m, 80, "IPX7 防水,12 小时续航,支持双机互联立体声。", ProductStatus.Active), - new("SKU-DIG-005", "8K HDMI 2.1 高清线 2m", "DIG-ACC-CABLE", 39.90m, 260, null, ProductStatus.Active), - new("SKU-DIG-006", "87 键机械键盘 红轴版", "DIG-PAD-ANDROID", 349.00m, 45, "PBT 键帽,全键无冲,支持有线 / 2.4G / 蓝牙三模连接。", ProductStatus.Active), - new("SKU-DIG-007", "轻量电竞鼠标 26000DPI", "DIG-PAD-ANDROID", 159.00m, 2, "轻量化 58g,旗舰传感器,6 档 DPI 可调。", ProductStatus.Active), // 低库存示例(仅剩 2 件) - new("SKU-DIG-008", "USB 3.2 固态U盘 128GB", "DIG-ACC-CABLE", 89.90m, 0, "读取速度 520MB/s,锌合金一体机身。", ProductStatus.Active), // 零库存但上架(缺货展示) - new("SKU-DIG-009", "铝合金显示器支架 单臂款", "DIG-PAD-ANDROID", 269.00m, 60, "气弹簧升降,兼容 17-32 英寸显示器,桌夹免打孔安装。", ProductStatus.Inactive), // 下架示例一 - new("SKU-DIG-010", "头戴式降噪耳机 Studio Max", "DIG-WEAR-WATCH", 2999.00m, 25, "旗舰级主动降噪,Hi-Res 金标认证,续航 40 小时。", ProductStatus.Active), - // ---- 家居生活 → 家纺/厨具/收纳 ---- - new("SKU-HOM-001", "100%纯棉四件套 1.8m 床", "HOM-BED", 299.00m, 120, "60 支长绒棉,A 类母婴级面料,裸睡亲肤。", ProductStatus.Active), // 名称含 "%" - new("SKU-HOM-002", "北欧风陶瓷马克杯 350ml", "HOM-KITCHEN", 29.90m, 400, "哑光釉面,杯身可进微波炉与洗碗机。", ProductStatus.Active), - new("SKU-HOM-003", "珊瑚绒加厚浴巾 70x140cm", "HOM-BED", 49.90m, 230, null, ProductStatus.Active), - new("SKU-HOM-004", "保温杯 316 不锈钢 500ml", "HOM-KITCHEN", 79.00m, 180, "316L 内胆,保温 12 小时,一键弹盖设计。", ProductStatus.Active), - new("SKU-HOM-005", "日式懒人沙发豆袋 灰色", "HOM-BED", 459.00m, 35, "EPP 粒子填充,外套可拆洗,客厅卧室两相宜。", ProductStatus.Active), - new("SKU-HOM-006", "香薰蜡烛礼盒 Sandalwood", "HOM-DECOR", 99.00m, 90, "天然大豆蜡,单支可燃约 45 小时,附礼盒包装。", ProductStatus.Active), - new("SKU-HOM-007", "天然乳胶枕 高低护颈款", "HOM-BED", 189.00m, 75, "人体工学高低曲线,蜂窝透气孔设计。", ProductStatus.Active), - new("SKU-HOM-008", "可折叠收纳箱 55L 三件装", "HOM-STORAGE", 119.00m, 210, "加厚 PP 材质,可折叠可叠放,底部带万向轮。", ProductStatus.Active), - new("SKU-HOM-009", "智能感应小夜灯 USB 充电款", "HOM-DECOR", 59.90m, 140, "人体感应 + 光控双模式,磁吸安装,USB-C 充电。", ProductStatus.Inactive), // 下架示例二 - new("SKU-HOM-010", "铸铁珐琅炖锅 24cm", "HOM-KITCHEN", 699.00m, 28, "铸铁珐琅内壁,蓄热均匀,适配电磁炉与明火。", ProductStatus.Active), - // ---- 图书文创 → 图书/文具/海报 ---- - new("SKU-BOK-001", "《三体》全三册 典藏版", "BOK-BOOK", 168.00m, 300, "刘慈欣科幻代表作,雨果奖获奖作品,赠典藏书签。", ProductStatus.Active), - new("SKU-BOK-002", "《活着》精装纪念版", "BOK-BOOK", 45.00m, 260, "余华代表作,精装纪念版。", ProductStatus.Active), - new("SKU-BOK-003", "Clean Code 代码整洁之道(中文版)", "BOK-BOOK", 79.00m, 110, "Robert C. Martin 经典软件工程读物,程序员案头书。", ProductStatus.Active), - new("SKU-BOK-004", "《小王子》中英双语插图版", "BOK-BOOK", 32.80m, 350, null, ProductStatus.Active), - new("SKU-BOK-005", "A5 网格笔记本 GridNote", "BOK-STATIONERY", 24.90m, 500, "80g 道林纸不洇墨,180 度可平摊装订。", ProductStatus.Active), - new("SKU-BOK-006", "复古钢笔礼盒装 EF 尖", "BOK-STATIONERY", 139.00m, 65, "复古旋帽设计,附 6 支墨囊与礼盒包装。", ProductStatus.Active), - new("SKU-BOK-007", "手帐和纸贴纸 30 张入", "BOK-STATIONERY", 9.90m, 800, "和纸材质,撕取不留胶,手帐拼贴必备。", ProductStatus.Active), - new("SKU-BOK-008", "《人类简史》修订新版", "BOK-BOOK", 68.00m, 190, "尤瓦尔·赫拉利成名作,理解人类文明的框架之书。", ProductStatus.Active), - new("SKU-BOK-009", "胡桃木书立 一对装", "BOK-STATIONERY", 36.00m, 95, "北美黑胡桃木打磨,底部防滑垫。", ProductStatus.Active), - new("SKU-BOK-010", "城市剪影海报 Shanghai Skyline", "BOK-POSTER", 55.00m, 70, "300g 铜版纸哑膜工艺,附无痕胶点。", ProductStatus.Active), - ]; + return; + } + + var seedBySku = productSeeds.ToDictionary(p => p.Sku, StringComparer.Ordinal); + var seedSkus = seedBySku.Keys.ToList(); + + var products = await _db.Set() + .Where(p => p.DeletedAt == null && seedSkus.Contains(p.Sku)) + .ToListAsync(ct); + + if (products.Count == 0) + { + return; + } + + var productIds = products.Select(p => p.Id).ToList(); + // 已有任意图片则跳过(幂等,不覆盖人工上传) + var withImages = await _db.Set() + .AsNoTracking() + .Where(i => productIds.Contains(i.ProductId)) + .Select(i => i.ProductId) + .Distinct() + .ToListAsync(ct); + var alreadyHasImages = withImages.ToHashSet(); + + var added = false; + long totalMaterialBytes = 0; + foreach (var product in products) + { + if (alreadyHasImages.Contains(product.Id) || !seedBySku.TryGetValue(product.Sku, out var seed)) + { + continue; + } + + var localPaths = CatalogSeedMaterialLoader.ResolveImagePaths( + _seedDirectory, + seed.Sku, + seed.MiMaterial, + _requireMaterial, + _requireReviewedArtifact); + + var payloads = new List<(byte[] Bytes, string Extension, string ContentHash)>(); + if (localPaths.Count > 0) + { + foreach (var path in localPaths.Take(MaxImagesPerProduct)) + { + var bytes = await File.ReadAllBytesAsync(path, ct); + CatalogSeedMaterialLoader.VerifyReviewedImageHash( + seed.MiMaterial, + path, + bytes, + _requireReviewedArtifact); + if (bytes.Length is <= 0 or > MaxSeedImageBytes) + { + throw new InvalidOperationException( + $"Seed 图片大小非法(1..{MaxSeedImageBytes} bytes):{path}"); + } + + totalMaterialBytes = checked(totalMaterialBytes + bytes.LongLength); + if (totalMaterialBytes > MaxSeedArtifactBytes) + { + throw new InvalidOperationException( + $"Seed 图片总量超过 {MaxSeedArtifactBytes} bytes。只有审核后的制品可以导入。"); + } + + var extension = DetectImageExtension(bytes); + EnsureExtensionMatches(path, extension); + payloads.Add((bytes, extension, CatalogSeedCoverPng.ContentHash(bytes))); + } + } + + if (payloads.Count == 0) + { + var generated = CatalogSeedCoverPng.Create(seed.Sku, product.Name, seed.CoverHue); + payloads.Add((generated, "png", CatalogSeedCoverPng.ContentHash(generated))); + } + + for (var i = 0; i < payloads.Count; i++) + { + var (bytes, extension, hash) = payloads[i]; + var storageKey = BuildSeedStorageKey($"{product.Sku}:{i}:{hash}", extension); + var absolutePath = ResolveUnderWebRoot(storageKey); + await WriteSeedFileAtomicallyAsync(absolutePath, bytes, hash, ct); + + var mime = extension switch + { + "jpg" => "image/jpeg", + "webp" => "image/webp", + _ => "image/png", + }; + + var image = ProductImage.Create( + product.Id, + storageKey, + publicUrl: "/" + storageKey, + mimeType: mime, + sizeBytes: bytes.LongLength, + contentHash: hash, + sortOrder: i, + isCover: i == 0); + _db.Set().Add(image); + added = true; + } + + product.IncrementVersion(); + } + + if (added) + { + await _db.SaveChangesAsync(ct); + } + } + + private static string DetectImageExtension(ReadOnlySpan bytes) + { + if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) + { + return "jpg"; + } + + if (bytes.Length >= 8 + && bytes[0] == 0x89 + && bytes[1] == (byte)'P' + && bytes[2] == (byte)'N' + && bytes[3] == (byte)'G' + && bytes[4] == 0x0D + && bytes[5] == 0x0A + && bytes[6] == 0x1A + && bytes[7] == 0x0A) + { + return "png"; + } + + if (bytes.Length >= 12 + && bytes[0] == (byte)'R' + && bytes[1] == (byte)'I' + && bytes[2] == (byte)'F' + && bytes[3] == (byte)'F' + && bytes[8] == (byte)'W' + && bytes[9] == (byte)'E' + && bytes[10] == (byte)'B' + && bytes[11] == (byte)'P') + { + return "webp"; + } + + throw new InvalidOperationException("Seed 图片魔数不是受支持的 JPEG、PNG 或 WebP。"); + } + + internal static string BuildSeedStorageKey(string sku, string extension = "png") + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sku))) + .ToLowerInvariant()[..16]; + var ext = string.IsNullOrWhiteSpace(extension) ? "png" : extension.Trim().TrimStart('.').ToLowerInvariant(); + return $"uploads/products/seed/{hash}.{ext}"; + } + + private string ResolveUnderWebRoot(string storageKey) + { + var segments = storageKey.Split('/', StringSplitOptions.RemoveEmptyEntries); + var parts = new string[segments.Length + 1]; + parts[0] = _webRootPath!; + Array.Copy(segments, 0, parts, 1, segments.Length); + var root = Path.GetFullPath(_webRootPath!) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var candidate = Path.GetFullPath(Path.Combine(parts)); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!candidate.StartsWith(root, comparison)) + { + throw new InvalidOperationException($"Seed 输出路径越界:{storageKey}"); + } + + return candidate; + } + + private async Task WriteSeedFileAtomicallyAsync( + string absolutePath, + byte[] bytes, + string expectedHash, + CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(absolutePath)!); + if (File.Exists(absolutePath)) + { + var existing = await File.ReadAllBytesAsync(absolutePath, ct); + if (!string.Equals( + CatalogSeedCoverPng.ContentHash(existing), + expectedHash, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Seed 输出文件已存在但内容不一致:{absolutePath}"); + } + + return; + } + + var temporaryPath = absolutePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllBytesAsync(temporaryPath, bytes, ct); + try + { + File.Move(temporaryPath, absolutePath); + _uncommittedFiles.Add(absolutePath); + } + catch (IOException) when (File.Exists(absolutePath)) + { + var existing = await File.ReadAllBytesAsync(absolutePath, ct); + if (!string.Equals( + CatalogSeedCoverPng.ContentHash(existing), + expectedHash, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Seed 输出文件并发写入了不同内容:{absolutePath}"); + } + } + } + finally + { + File.Delete(temporaryPath); + } + } + + private static void EnsureExtensionMatches(string path, string detectedExtension) + { + var declaredExtension = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(); + if (declaredExtension == "jpeg") + { + declaredExtension = "jpg"; + } + + if (!string.Equals(declaredExtension, detectedExtension, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Seed 图片扩展名与魔数不一致:{path}(检测为 {detectedExtension})"); + } + } + + private static byte StableCoverHue(string sku) + => SHA256.HashData(Encoding.UTF8.GetBytes(sku))[0]; + + private static string Truncate(string value, int max) + { + var trimmed = value.Trim(); + return trimmed.Length <= max ? trimmed : trimmed[..max]; + } } diff --git a/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDemoProductCatalog.cs b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDemoProductCatalog.cs new file mode 100644 index 0000000..2b725cb --- /dev/null +++ b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogDemoProductCatalog.cs @@ -0,0 +1,750 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using EShop.Modules.Catalog.Domain; + +namespace EShop.Modules.Catalog.Application.Seeding; + +/// +/// 演示目录:公开市场常见高销品类/型号(名称与参数为课堂演示整理,非平台抓取)。 +/// 图片由 本地生成,不引用京东/淘宝/小米等站外原图。 +/// +internal static class CatalogDemoProductCatalog +{ + internal sealed record CategorySeed(string Code, string Name, int SortOrder, string? ParentCode); + + internal sealed record ProductSeed( + string Sku, + string Name, + string CategoryCode, + decimal UnitPrice, + int StockQuantity, + string? Description, + string Status, + string? Brand, + int SalesCount, + string? SpecificationsJson, + byte CoverHue, + string? CoverFileName = null, + CatalogSeedMaterialLoader.MiProduct? MiMaterial = null); + + private static readonly JsonSerializerOptions SpecJsonOptions = new() + { + // 规格表直接面向中文详情页展示,保持可读 JSON,不转义为 \uXXXX + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + internal static readonly CategorySeed[] Categories = + [ + // 顶级 + new("DIG", "手机数码", 1, null), + new("HOM", "家居生活", 2, null), + new("BOK", "图书文创", 3, null), + new("CLO", "服饰鞋包", 4, null), + new("FOO", "食品生鲜", 5, null), + new("BEA", "美妆个护", 6, null), + + // 手机数码 + new("DIG-PHONE", "手机", 1, "DIG"), + new("DIG-PAD", "平板电脑", 2, "DIG"), + new("DIG-WEAR", "智能穿戴", 3, "DIG"), + new("DIG-ACC", "手机配件", 4, "DIG"), + new("DIG-AUDIO", "耳机音箱", 5, "DIG"), + new("DIG-NOTE", "笔记本", 6, "DIG"), + new("DIG-TV", "电视影音", 7, "DIG"), + new("DIG-NET", "路由器网络", 8, "DIG"), + + new("DIG-PHONE-SMART", "智能手机", 1, "DIG-PHONE"), + new("DIG-PHONE-FEATURE", "功能机", 2, "DIG-PHONE"), + new("DIG-PAD-IPAD", "iPad", 1, "DIG-PAD"), + new("DIG-PAD-ANDROID", "安卓平板", 2, "DIG-PAD"), + new("DIG-WEAR-WATCH", "智能手表", 1, "DIG-WEAR"), + new("DIG-WEAR-BAND", "智能手环", 2, "DIG-WEAR"), + new("DIG-ACC-CASE", "手机壳", 1, "DIG-ACC"), + new("DIG-ACC-CHARGER", "充电器", 2, "DIG-ACC"), + new("DIG-ACC-CABLE", "数据线", 3, "DIG-ACC"), + + // 家居 / 家电 + new("HOM-BED", "家纺", 1, "HOM"), + new("HOM-KITCHEN", "厨具", 2, "HOM"), + new("HOM-STORAGE", "收纳整理", 3, "HOM"), + new("HOM-DECOR", "家居饰品", 4, "HOM"), + new("HOM-APPLIANCE", "厨房小电", 5, "HOM"), + new("HOM-CLIMATE", "空调冰洗", 6, "HOM"), + new("HOM-CLEAN", "清洁电器", 7, "HOM"), + + // 图书文创 + new("BOK-BOOK", "图书", 1, "BOK"), + new("BOK-STATIONERY", "文具", 2, "BOK"), + new("BOK-POSTER", "海报文创", 3, "BOK"), + + // 服饰 + new("CLO-MEN", "男装", 1, "CLO"), + new("CLO-WOMEN", "女装", 2, "CLO"), + new("CLO-SHOES", "鞋类", 3, "CLO"), + new("CLO-BAG", "箱包", 4, "CLO"), + + // 食品 + new("FOO-SNACK", "休闲零食", 1, "FOO"), + new("FOO-DRINK", "饮料冲调", 2, "FOO"), + new("FOO-FRESH", "生鲜水果", 3, "FOO"), + + // 美妆 + new("BEA-SKIN", "护肤", 1, "BEA"), + new("BEA-MAKEUP", "彩妆", 2, "BEA"), + new("BEA-HAIR", "洗护发", 3, "BEA"), + ]; + + internal static readonly ProductSeed[] Products = BuildProducts(); + + private static ProductSeed[] BuildProducts() + { + var list = new List(64); + + // ---------- 智能手机(公开市场热销型号整理,演示价) ---------- + list.Add(P( + "SKU-PHONE-001", "Xiaomi 17 Pro Max 12GB+256GB 星空黑", "DIG-PHONE-SMART", + 6499m, 86, "Xiaomi", 128_600, + "数字旗舰大杯,影像与性能均衡,适合作为演示主推机型。", + Spec(("屏幕", "6.9英寸 2K 高刷 OLED"), ("处理器", "骁龙 8 旗舰平台"), ("运存/存储", "12GB+256GB"), + ("电池", "6000mAh 级"), ("充电", "90W 有线 / 50W 无线"), ("网络", "5G 全网通"), ("颜色", "星空黑")), + ProductStatus.Active, 12)); + + list.Add(P( + "SKU-PHONE-002", "Xiaomi 17 Ultra 徕卡影像版 16GB+512GB", "DIG-PHONE-SMART", + 7999m, 42, "Xiaomi", 56_200, + "影像旗舰演示款,参数面向详情页规格表与高客单结算场景。", + Spec(("屏幕", "6.73英寸 LTPO OLED"), ("主摄", "1 英寸主摄 + 徕卡光学"), ("运存/存储", "16GB+512GB"), + ("电池", "5400mAh"), ("充电", "90W 有线"), ("防尘防水", "IP68"), ("颜色", "钛金属灰")), + ProductStatus.Active, 28)); + + list.Add(P( + "SKU-PHONE-003", "REDMI Note 17 Pro 12GB+256GB", "DIG-PHONE-SMART", + 1599m, 260, "REDMI", 312_400, + "抗摔防水长续航,性价比爆款,适合列表筛选与高销量排序演示。", + Spec(("屏幕", "6.77英寸 1.5K 高刷"), ("处理器", "天玑中端平台"), ("运存/存储", "12GB+256GB"), + ("电池", "7000mAh 级"), ("充电", "45W"), ("特性", "抗摔 / 防水溅"), ("颜色", "星岩黑")), + ProductStatus.Active, 44)); + + list.Add(P( + "SKU-PHONE-004", "REDMI Note 17 8GB+256GB", "DIG-PHONE-SMART", + 1299m, 310, "REDMI", 268_900, + "大屏大电量入门优选,覆盖低价位段加购与库存扣减测试。", + Spec(("屏幕", "6.9英寸 护眼屏"), ("运存/存储", "8GB+256GB"), ("电池", "6600mAh"), + ("充电", "33W"), ("音频", "立体声双扬"), ("颜色", "冰霜蓝")), + ProductStatus.Active, 56)); + + list.Add(P( + "SKU-PHONE-005", "REDMI K90 至尊版 12GB+256GB", "DIG-PHONE-SMART", + 3299m, 120, "REDMI", 94_500, + "狂暴双芯与主动散热卖点,用于高刷游戏机型演示。", + Spec(("屏幕", "6.83英寸 1.5K 165Hz"), ("处理器", "旗舰双芯"), ("运存/存储", "12GB+256GB"), + ("散热", "风冷主动散热"), ("电池", "6500mAh"), ("充电", "100W"), ("颜色", "暗影紫")), + ProductStatus.Active, 72)); + + list.Add(P( + "SKU-PHONE-006", "REDMI K90 Max 16GB+512GB", "DIG-PHONE-SMART", + 3499m, 88, "REDMI", 61_300, + "K 系列顶配演示,覆盖更高存储规格筛选。", + Spec(("屏幕", "6.83英寸 165Hz"), ("运存/存储", "16GB+512GB"), ("电池", "6500mAh"), + ("充电", "120W"), ("影像", "5000 万主摄"), ("颜色", "苍穹银")), + ProductStatus.Active, 88)); + + list.Add(P( + "SKU-PHONE-007", "REDMI 17C 6GB+128GB", "DIG-PHONE-SMART", + 799m, 520, "REDMI", 401_200, + "千元以下走量机,适合大批量库存与低价购物车场景。", + Spec(("屏幕", "6.88英寸 大屏"), ("运存/存储", "6GB+128GB"), ("主摄", "1300 万"), + ("电池", "5000mAh"), ("充电", "18W"), ("颜色", "午夜黑")), + ProductStatus.Active, 100)); + + list.Add(P( + "SKU-PHONE-008", "Xiaomi 17T 12GB+256GB", "DIG-PHONE-SMART", + 2999m, 150, "Xiaomi", 73_800, + "轻薄影像中杯,用于中价位带详情与促销对照。", + Spec(("屏幕", "6.67英寸 OLED"), ("运存/存储", "12GB+256GB"), ("影像", "光学防抖主摄"), + ("电池", "5500mAh"), ("充电", "67W"), ("颜色", "雪青")), + ProductStatus.Active, 116)); + + list.Add(P( + "SKU-PHONE-009", "Xiaomi Civi 5 Pro 12GB+512GB 星云紫", "DIG-PHONE-SMART", + 3499m, 96, "Xiaomi", 48_100, + "轻薄颜值向机型,覆盖品牌搜索「Civi」。", + Spec(("屏幕", "6.55英寸 居中挖孔 OLED"), ("处理器", "第四代骁龙 8s"), ("运存/存储", "12GB+512GB"), + ("厚度", "约 7.5mm 级"), ("电池", "5200mAh"), ("颜色", "星云紫")), + ProductStatus.Active, 132)); + + // ---------- 平板 ---------- + list.Add(P( + "SKU-PAD-001", "Xiaomi Pad 8 Pro 8GB+256GB", "DIG-PAD-ANDROID", + 2999m, 110, "Xiaomi", 52_700, + "高刷大屏平板,演示学习办公场景加购。", + Spec(("屏幕", "11.2英寸 3K 高刷"), ("处理器", "旗舰平板平台"), ("运存/存储", "8GB+256GB"), + ("电池", "10000mAh 级"), ("充电", "67W"), ("配件", "支持磁吸键盘/手写笔")), + ProductStatus.Active, 20)); + + list.Add(P( + "SKU-PAD-002", "Xiaomi Pad 8 8GB+128GB", "DIG-PAD-ANDROID", + 2799m, 130, "Xiaomi", 41_200, + "Pad 8 标准版,用于价位对比。", + Spec(("屏幕", "11.2英寸 2.5K"), ("运存/存储", "8GB+128GB"), ("电池", "10000mAh 级"), + ("充电", "67W"), ("扬声器", "四扬声器")), + ProductStatus.Active, 36)); + + list.Add(P( + "SKU-PAD-003", "REDMI Pad 2 Pro 8GB+256GB", "DIG-PAD-ANDROID", + 1999m, 180, "REDMI", 89_400, + "性价比平板爆款。", + Spec(("屏幕", "12.1英寸 2.5K"), ("运存/存储", "8GB+256GB"), ("电池", "10000mAh"), + ("充电", "33W"), ("颜色", "石墨灰")), + ProductStatus.Active, 52)); + + list.Add(P( + "SKU-PAD-004", "REDMI Pad 2 6GB+128GB", "DIG-PAD-ANDROID", + 1299m, 240, "REDMI", 112_600, + "入门娱乐平板。", + Spec(("屏幕", "11英寸"), ("运存/存储", "6GB+128GB"), ("电池", "9000mAh"), + ("充电", "18W"), ("颜色", "薄荷绿")), + ProductStatus.Active, 68)); + + list.Add(P( + "SKU-PAD-005", "REDMI Pad 2 SE 4GB+128GB", "DIG-PAD-ANDROID", + 1199m, 200, "REDMI", 76_300, + "更低配位平板,覆盖筛选边界。", + Spec(("屏幕", "11英寸"), ("运存/存储", "4GB+128GB"), ("电池", "8000mAh"), + ("充电", "18W")), + ProductStatus.Active, 84)); + + // ---------- 穿戴 ---------- + list.Add(P( + "SKU-WEAR-001", "小米手环 10 Pro", "DIG-WEAR-BAND", + 399m, 420, "Xiaomi", 186_500, + "健康监测与长续航手环旗舰。", + Spec(("屏幕", "1.74英寸 AMOLED"), ("续航", "约 16 天"), ("防水", "5ATM"), + ("传感器", "血氧/心率/睡眠"), ("系统", "HyperOS 穿戴")), + ProductStatus.Active, 8)); + + list.Add(P( + "SKU-WEAR-002", "小米手环 10 标准版", "DIG-WEAR-BAND", + 249m, 560, "Xiaomi", 221_000, + "走量手环,适合配件连带购买演示。", + Spec(("屏幕", "1.62英寸 AMOLED"), ("续航", "约 21 天"), ("防水", "5ATM"), + ("重量", "约 16g")), + ProductStatus.Active, 24)); + + list.Add(P( + "SKU-WEAR-003", "Xiaomi Watch S5", "DIG-WEAR-WATCH", + 1299m, 140, "Xiaomi", 38_900, + "圆形智能手表,运动与通勤双场景。", + Spec(("屏幕", "1.43英寸 AMOLED"), ("材质", "不锈钢中框"), ("续航", "约 15 天综合"), + ("定位", "双频 GPS"), ("防水", "5ATM")), + ProductStatus.Active, 40)); + + list.Add(P( + "SKU-WEAR-004", "REDMI Watch 6", "DIG-WEAR-WATCH", + 399m, 300, "REDMI", 95_200, + "大屏轻智能手表。", + Spec(("屏幕", "2.07英寸"), ("续航", "约 18 天"), ("运动模式", "150+"), + ("通话", "蓝牙通话")), + ProductStatus.Active, 60)); + + // ---------- 耳机音箱 ---------- + list.Add(P( + "SKU-AUDIO-001", "REDMI Buds 8 Pro", "DIG-AUDIO", + 399m, 280, "REDMI", 142_300, + "主动降噪真无线,综合续航优秀。", + Spec(("降噪", "深度主动降噪"), ("续航", "综合约 38 小时"), ("协议", "蓝牙 5.4"), + ("防水", "IP54"), ("充电", "支持无线充电")), + ProductStatus.Active, 16)); + + list.Add(P( + "SKU-AUDIO-002", "Xiaomi Buds 5 Pro", "DIG-AUDIO", + 699m, 160, "Xiaomi", 54_800, + "旗舰真无线,Hi-Fi 调校卖点。", + Spec(("单元", "三单元"), ("降噪", "自适应 ANC"), ("续航", "综合约 40 小时"), + ("编码", "LHDC/LDAC"), ("防水", "IP54")), + ProductStatus.Active, 32)); + + list.Add(P( + "SKU-AUDIO-003", "REDMI 头戴式降噪耳机", "DIG-AUDIO", + 399m, 190, "REDMI", 67_400, + "头戴降噪,长途通勤演示。", + Spec(("类型", "头戴式"), ("降噪", "混合主动降噪"), ("续航", "约 70 小时"), + ("接口", "3.5mm / Type-C"), ("折叠", "转轴折叠")), + ProductStatus.Active, 48)); + + list.Add(P( + "SKU-AUDIO-004", "Xiaomi 蓝牙音箱 Mini", "DIG-AUDIO", + 99m, 450, "Xiaomi", 203_100, + "便携音箱,低价高转化。", + Spec(("功率", "5W"), ("防水", "IP67"), ("续航", "约 8 小时"), + ("连接", "蓝牙 5.3"), ("重量", "约 180g")), + ProductStatus.Active, 64)); + + list.Add(P( + "SKU-AUDIO-005", "Xiaomi Sound Pro 智能音箱", "DIG-AUDIO", + 699m, 95, "Xiaomi", 29_600, + "家用智能音箱,联动智能家居话术。", + Spec(("功率", "40W"), ("声道", "立体声"), ("助手", "小爱同学"), + ("连接", "Wi-Fi / 蓝牙"), ("麦阵", "远场 6 麦")), + ProductStatus.Active, 80)); + + // ---------- 笔记本 / 电视 / 路由 ---------- + list.Add(P( + "SKU-NOTE-001", "REDMI Book Pro 14 2026 超能版", "DIG-NOTE", + 7199m, 55, "REDMI", 18_400, + "轻薄高性能本,适合高客单订单演示。", + Spec(("屏幕", "14英寸 2.8K 120Hz OLED"), ("处理器", "酷睿 Ultra 系列"), + ("内存", "32GB LPDDR5X"), ("硬盘", "1TB SSD"), ("重量", "约 1.35kg")), + ProductStatus.Active, 18)); + + list.Add(P( + "SKU-NOTE-002", "REDMI Book 14 2026", "DIG-NOTE", + 5699m, 70, "REDMI", 24_900, + "主流轻薄本。", + Spec(("屏幕", "14英寸 2.5K"), ("处理器", "酷睿 5 系列"), ("内存", "16GB"), + ("硬盘", "512GB SSD"), ("接口", "双雷电 / HDMI")), + ProductStatus.Active, 34)); + + list.Add(P( + "SKU-NOTE-003", "REDMI Book 16 2026", "DIG-NOTE", + 5899m, 62, "REDMI", 21_100, + "大屏办公本。", + Spec(("屏幕", "16英寸 2.5K"), ("内存", "16GB"), ("硬盘", "512GB SSD"), + ("续航", "约 16 小时办公"), ("键盘", "全尺寸数字键盘")), + ProductStatus.Active, 50)); + + list.Add(P( + "SKU-TV-001", "小米电视 S Mini LED 65英寸 2026", "DIG-TV", + 4199m, 40, "Xiaomi", 33_200, + "Mini LED 旗舰电视演示款。", + Spec(("尺寸", "65英寸"), ("背光", "Mini LED"), ("刷新率", "144Hz"), + ("系统", "澎湃 OS 电视版"), ("音响", "2.1 声道"), ("接口", "4×HDMI 2.1")), + ProductStatus.Active, 22)); + + list.Add(P( + "SKU-TV-002", "REDMI 电视 X 55英寸 2026", "DIG-TV", + 2999m, 75, "REDMI", 48_700, + "游戏高刷电视。", + Spec(("尺寸", "55英寸"), ("分辨率", "4K"), ("刷新率", "144Hz"), + ("MEMC", "支持"), ("系统", "Google TV / 澎湃视情况")), + ProductStatus.Active, 38)); + + list.Add(P( + "SKU-TV-003", "REDMI A Pro 50英寸", "DIG-TV", + 1749m, 110, "REDMI", 66_500, + "性价比爆款电视。", + Spec(("尺寸", "50英寸"), ("分辨率", "4K"), ("刷新率", "60Hz"), + ("系统", "智能电视系统"), ("壁挂", "支持")), + ProductStatus.Active, 54)); + + list.Add(P( + "SKU-NET-001", "Xiaomi 路由器 AX3000T", "DIG-NET", + 139m, 600, "Xiaomi", 255_800, + "千兆 Wi-Fi 6 路由,配件区走量王。", + Spec(("协议", "Wi-Fi 6"), ("速率", "AX3000"), ("网口", "1G×4"), + ("频段", "2.4G/5G 双频"), ("Mesh", "支持")), + ProductStatus.Active, 10)); + + list.Add(P( + "SKU-NET-002", "Xiaomi 路由器 BE3600", "DIG-NET", + 169m, 380, "Xiaomi", 98_400, + "Wi-Fi 7 入门路由。", + Spec(("协议", "Wi-Fi 7"), ("速率", "BE3600"), ("网口", "2.5G×1 + 1G×3"), + ("频段", "双频")), + ProductStatus.Active, 26)); + + // ---------- 配件(保留边界 SKU:低库存/零库存/下架) ---------- + list.Add(P( + "SKU-DIG-001", "小米 Type-C 快充数据线 1.5m 100W", "DIG-ACC-CABLE", + 29.90m, 800, "Xiaomi", 512_000, + "支持 PD 100W 快充,尼龙编织,兼容主流安卓与平板。", + Spec(("接口", "USB-C to USB-C"), ("功率", "100W"), ("长度", "1.5m"), + ("线材", "尼龙编织"), ("协议", "PD/QC")), + ProductStatus.Active, 4)); + + list.Add(P( + "SKU-DIG-002", "小米 67W 氮化镓充电器 套装", "DIG-ACC-CHARGER", + 129m, 420, "Xiaomi", 188_600, + "单口 67W,兼容 PD/QC,附 Type-C 线。", + Spec(("功率", "67W"), ("工艺", "GaN 氮化镓"), ("接口", "USB-C"), + ("协议", "PD/QC/PPS"), ("体积", "口袋级")), + ProductStatus.Active, 14)); + + list.Add(P( + "SKU-DIG-003", "Xiaomi 车载充电器快充版 1A1C 100W", "DIG-ACC-CHARGER", + 99m, 260, "Xiaomi", 74_200, + "车载快充,覆盖配件检索「车载」。", + Spec(("功率", "100W 总输出"), ("接口", "USB-A + USB-C"), ("输入", "12-24V"), + ("安全", "过流/过温保护")), + ProductStatus.Active, 30)); + + list.Add(P( + "SKU-DIG-004", "Xiaomi 磁吸手机壳 透明款", "DIG-ACC-CASE", + 69m, 350, "Xiaomi", 61_800, + "MagSafe 兼容磁吸壳。", + Spec(("材质", "PC+TPU"), ("兼容", "磁吸无线充"), ("特性", "防摔气囊角"), + ("颜色", "透明")), + ProductStatus.Active, 46)); + + list.Add(P( + "SKU-DIG-005", "8K HDMI 2.1 高清线 2m", "DIG-ACC-CABLE", + 39.90m, 260, "绿联", 45_000, + "电视/主机配件。", + Spec(("规格", "HDMI 2.1"), ("带宽", "48Gbps"), ("长度", "2m"), + ("分辨率", "8K@60Hz / 4K@120Hz")), + ProductStatus.Active, 62)); + + list.Add(P( + "SKU-DIG-006", "87 键机械键盘 红轴三模", "DIG-PAD-ANDROID", + 349m, 45, "Keychron", 22_400, + "PBT 键帽,有线 / 2.4G / 蓝牙三模。", + Spec(("配列", "87 键"), ("轴体", "红轴"), ("连接", "三模"), + ("键帽", "PBT"), ("热插拔", "支持")), + ProductStatus.Active, 78)); + + // 低库存 + list.Add(P( + "SKU-DIG-007", "轻量电竞鼠标 26000DPI", "DIG-PAD-ANDROID", + 159m, 2, "雷蛇", 18_900, + "轻量化 58g,旗舰传感器,6 档 DPI 可调(低库存演示)。", + Spec(("DPI", "26000"), ("重量", "58g"), ("连接", "2.4G 无线"), + ("续航", "约 90 小时")), + ProductStatus.Active, 90)); + + // 零库存上架 + list.Add(P( + "SKU-DIG-008", "USB 3.2 固态 U 盘 128GB", "DIG-ACC-CABLE", + 89.90m, 0, "闪迪", 33_500, + "读取约 520MB/s,锌合金机身(零库存但上架演示)。", + Spec(("容量", "128GB"), ("接口", "USB-A / USB-C 双头"), ("读取", "520MB/s"), + ("材质", "锌合金")), + ProductStatus.Active, 106)); + + // 下架 + list.Add(P( + "SKU-DIG-009", "铝合金显示器支架 单臂款", "DIG-PAD-ANDROID", + 269m, 60, "北弧", 9_200, + "气弹簧升降,兼容 17-32 英寸(下架演示)。", + Spec(("承重", "9kg"), ("尺寸", "17-32 英寸"), ("安装", "桌夹免打孔"), + ("材质", "铝合金")), + ProductStatus.Inactive, 122)); + + list.Add(P( + "SKU-DIG-010", "头戴式降噪耳机 Studio Max", "DIG-AUDIO", + 2999m, 25, "索尼", 12_600, + "旗舰级主动降噪,Hi-Res 认证,续航 40 小时。", + Spec(("降噪", "旗舰 ANC"), ("续航", "40 小时"), ("认证", "Hi-Res"), + ("编码", "LDAC"), ("充电", "USB-C")), + ProductStatus.Active, 138)); + + // ---------- 家电家居 ---------- + list.Add(P( + "SKU-HOM-AC1", "小米空调 1.5 匹新1级能效 变频挂机", "HOM-CLIMATE", + 1688m, 70, "米家", 86_400, + "新1级能效变频挂机,夏季爆款。", + Spec(("匹数", "1.5 匹"), ("能效", "新1级"), ("类型", "变频挂机"), + ("冷暖", "冷暖"), ("智能", "米家 App / 小爱")), + ProductStatus.Active, 15)); + + list.Add(P( + "SKU-HOM-WM1", "米家洗衣机 洗烘一体 12kg", "HOM-CLIMATE", + 1899m, 48, "米家", 41_700, + "大容量洗烘,家庭用户演示。", + Spec(("容量", "12kg 洗烘"), ("类型", "滚筒洗烘一体"), ("能效", "1 级"), + ("烘干", "热泵/冷凝视款型"), ("智能", "米家联动")), + ProductStatus.Active, 31)); + + list.Add(P( + "SKU-HOM-RF1", "米家冰箱 对开门 636L", "HOM-CLIMATE", + 1999m, 35, "米家", 28_900, + "大容积对开门冰箱。", + Spec(("容积", "636L"), ("门体", "对开门"), ("制冷", "风冷无霜"), + ("能效", "1 级"), ("变温", "支持")), + ProductStatus.Active, 47)); + + list.Add(P( + "SKU-HOM-RB1", "米家扫拖机器人 扫拖一体", "HOM-CLEAN", + 1999m, 90, "米家", 63_200, + "智能清洁电器爆款。", + Spec(("导航", "LDS / 视觉融合"), ("吸力", "6000Pa 级"), ("续航", "约 180 分钟"), + ("集尘", "自动集尘可选"), ("App", "米家")), + ProductStatus.Active, 63)); + + list.Add(P( + "SKU-HOM-AP1", "米家空气净化器 4 Pro", "HOM-CLEAN", + 1299m, 120, "米家", 54_100, + "大空间净化,颗粒物 CADR 高。", + Spec(("CADR 颗粒物", "500m³/h 级"), ("适用面积", "35-60㎡"), ("滤芯", "高效复合"), + ("噪音", "睡眠档 ≤34dB"), ("智能", "米家 App")), + ProductStatus.Active, 79)); + + list.Add(P( + "SKU-HOM-KT1", "米家电饭煲 4L 智能版", "HOM-APPLIANCE", + 399m, 200, "米家", 97_800, + "IH 电磁加热,多模式煮粥煲汤。", + Spec(("容量", "4L"), ("加热", "IH"), ("内胆", "不粘涂层"), + ("预约", "24 小时"), ("智能", "米家 App")), + ProductStatus.Active, 95)); + + // 名称含 %(搜索字面量) + list.Add(P( + "SKU-HOM-001", "100%纯棉四件套 1.8m 床", "HOM-BED", + 299m, 120, "水星家纺", 42_300, + "60 支长绒棉,A 类母婴级面料,裸睡亲肤。", + Spec(("材质", "100% 棉"), ("支数", "60 支"), ("规格", "1.8m 床"), + ("工艺", "活性印染"), ("等级", "A 类")), + ProductStatus.Active, 11)); + + list.Add(P( + "SKU-HOM-002", "北欧风陶瓷马克杯 350ml", "HOM-KITCHEN", + 29.90m, 400, "摩登主妇", 58_000, + "哑光釉面,可进微波炉与洗碗机。", + Spec(("容量", "350ml"), ("材质", "陶瓷"), ("适用", "微波炉/洗碗机"), + ("风格", "北欧哑光")), + ProductStatus.Active, 27)); + + list.Add(P( + "SKU-HOM-003", "珊瑚绒加厚浴巾 70x140cm", "HOM-BED", + 49.90m, 230, "洁丽雅", 71_200, + "加厚吸水,家用酒店两相宜。", + Spec(("尺寸", "70x140cm"), ("材质", "珊瑚绒"), ("克重", "400g/条 级"), + ("颜色", "浅灰")), + ProductStatus.Active, 43)); + + list.Add(P( + "SKU-HOM-004", "保温杯 316 不锈钢 500ml", "HOM-KITCHEN", + 79m, 180, "富光", 132_400, + "316L 内胆,保温 12 小时,一键弹盖。", + Spec(("容量", "500ml"), ("材质", "316L 不锈钢"), ("保温", "12 小时"), + ("盖型", "一键弹盖")), + ProductStatus.Active, 59)); + + list.Add(P( + "SKU-HOM-005", "日式懒人沙发豆袋 灰色", "HOM-BED", + 459m, 35, "源氏木语", 9_800, + "EPP 粒子填充,外套可拆洗。", + Spec(("填充", "EPP 粒子"), ("外套", "可拆洗"), ("颜色", "灰色"), + ("场景", "客厅/卧室")), + ProductStatus.Active, 75)); + + list.Add(P( + "SKU-HOM-006", "香薰蜡烛礼盒 Sandalwood", "HOM-DECOR", + 99m, 90, "野兽派", 15_600, + "天然大豆蜡,单支约 45 小时。", + Spec(("香型", "檀香"), ("材质", "大豆蜡"), ("燃烧", "约 45 小时"), + ("包装", "礼盒")), + ProductStatus.Active, 91)); + + list.Add(P( + "SKU-HOM-007", "天然乳胶枕 高低护颈款", "HOM-BED", + 189m, 75, "邓禄普", 27_400, + "人体工学高低曲线,蜂窝透气。", + Spec(("材质", "天然乳胶"), ("结构", "高低护颈"), ("透气", "蜂窝孔"), + ("包装", "真空压缩")), + ProductStatus.Active, 107)); + + list.Add(P( + "SKU-HOM-008", "可折叠收纳箱 55L 三件装", "HOM-STORAGE", + 119m, 210, "禧天龙", 48_900, + "加厚 PP,可折叠叠放,底部万向轮。", + Spec(("容量", "55L×3"), ("材质", "PP"), ("结构", "可折叠"), + ("轮子", "万向轮")), + ProductStatus.Active, 123)); + + // 下架示例 + list.Add(P( + "SKU-HOM-009", "智能感应小夜灯 USB 充电款", "HOM-DECOR", + 59.90m, 140, "米家", 36_700, + "人体感应 + 光控,磁吸安装(下架演示)。", + Spec(("感应", "人体/光控"), ("安装", "磁吸"), ("充电", "USB-C"), + ("续航", "约 30 天待机")), + ProductStatus.Inactive, 139)); + + list.Add(P( + "SKU-HOM-010", "铸铁珐琅炖锅 24cm", "HOM-KITCHEN", + 699m, 28, "北鼎", 11_200, + "铸铁珐琅内壁,适配电磁炉与明火。", + Spec(("直径", "24cm"), ("材质", "铸铁珐琅"), ("炉具", "电磁炉/明火"), + ("颜色", "祖母绿")), + ProductStatus.Active, 155)); + + // ---------- 图书 / 服饰 / 食品 / 美妆(类目覆盖) ---------- + list.Add(P( + "SKU-BOK-001", "《三体》全三册 典藏版", "BOK-BOOK", + 168m, 300, "重庆出版社", 210_000, + "刘慈欣科幻代表作,雨果奖获奖作品。", + Spec(("作者", "刘慈欣"), ("册数", "3"), ("装帧", "平装典藏"), + ("ISBN", "演示用非真实 ISBN")), + ProductStatus.Active, 5)); + + list.Add(P( + "SKU-BOK-002", "《活着》精装纪念版", "BOK-BOOK", + 45m, 260, "作家出版社", 156_000, + "余华代表作,精装纪念版。", + Spec(("作者", "余华"), ("装帧", "精装"), ("页数", "约 190")), + ProductStatus.Active, 21)); + + list.Add(P( + "SKU-BOK-003", "Clean Code 代码整洁之道(中文版)", "BOK-BOOK", + 79m, 110, "人民邮电出版社", 48_500, + "Robert C. Martin 经典软件工程读物。", + Spec(("作者", "Robert C. Martin"), ("译者", "韩磊"), ("类别", "计算机")), + ProductStatus.Active, 37)); + + list.Add(P( + "SKU-BOK-004", "《小王子》中英双语插图版", "BOK-BOOK", + 32.80m, 350, "天津人民出版社", 98_700, + "经典童话双语插图版。", + Spec(("语言", "中英双语"), ("装帧", "平装"), ("插图", "彩色")), + ProductStatus.Active, 53)); + + list.Add(P( + "SKU-BOK-005", "A5 网格笔记本 GridNote", "BOK-STATIONERY", + 24.90m, 500, "国誉", 67_300, + "80g 道林纸,180 度平摊。", + Spec(("尺寸", "A5"), ("纸张", "80g 道林"), ("内页", "网格"), + ("装订", "胶装平摊")), + ProductStatus.Active, 69)); + + list.Add(P( + "SKU-BOK-006", "复古钢笔礼盒装 EF 尖", "BOK-STATIONERY", + 139m, 65, "英雄", 12_100, + "复古旋帽,附墨囊与礼盒。", + Spec(("笔尖", "EF"), ("材质", "金属笔杆"), ("附件", "6 支墨囊"), + ("包装", "礼盒")), + ProductStatus.Active, 85)); + + list.Add(P( + "SKU-BOK-007", "手帐和纸贴纸 30 张入", "BOK-STATIONERY", + 9.90m, 800, "手帐生活", 88_900, + "和纸材质,撕取不留胶。", + Spec(("数量", "30 张"), ("材质", "和纸"), ("背胶", "可再贴")), + ProductStatus.Active, 101)); + + list.Add(P( + "SKU-BOK-008", "《人类简史》修订新版", "BOK-BOOK", + 68m, 190, "中信出版社", 134_500, + "尤瓦尔·赫拉利成名作。", + Spec(("作者", "尤瓦尔·赫拉利"), ("装帧", "平装"), ("类别", "社科")), + ProductStatus.Active, 117)); + + list.Add(P( + "SKU-BOK-009", "胡桃木书立 一对装", "BOK-STATIONERY", + 36m, 95, "木以成居", 19_800, + "北美黑胡桃木,底部防滑。", + Spec(("材质", "黑胡桃木"), ("数量", "一对"), ("防滑", "底部垫")), + ProductStatus.Active, 133)); + + list.Add(P( + "SKU-BOK-010", "城市剪影海报 Shanghai Skyline", "BOK-POSTER", + 55m, 70, "印物所", 8_600, + "300g 铜版纸哑膜,附无痕胶点。", + Spec(("尺寸", "50x70cm"), ("纸张", "300g 铜版"), ("工艺", "哑膜"), + ("附件", "无痕胶点")), + ProductStatus.Active, 149)); + + list.Add(P( + "SKU-CLO-001", "男士纯棉圆领短袖 T 恤", "CLO-MEN", + 79m, 320, "优衣库", 201_000, + "基础款短袖,多色可选(演示单色)。", + Spec(("材质", "100% 棉"), ("克重", "180g"), ("版型", "合身"), + ("颜色", "白色"), ("尺码", "M-XXL")), + ProductStatus.Active, 17)); + + list.Add(P( + "SKU-CLO-002", "女士直筒牛仔裤 高腰", "CLO-WOMEN", + 199m, 160, "UR", 64_300, + "高腰直筒,弹力舒适。", + Spec(("腰型", "高腰"), ("版型", "直筒"), ("材质", "棉+弹力纤维"), + ("颜色", "中蓝")), + ProductStatus.Active, 33)); + + list.Add(P( + "SKU-CLO-003", "透气跑步运动鞋", "CLO-SHOES", + 299m, 140, "安踏", 88_100, + "轻质缓震,适合日常跑步。", + Spec(("鞋面", "网布"), ("中底", "轻质缓震"), ("鞋底", "防滑橡胶"), + ("场景", "跑步/通勤")), + ProductStatus.Active, 49)); + + list.Add(P( + "SKU-CLO-004", "商务双肩背包 15.6 寸", "CLO-BAG", + 259m, 110, "小米", 45_600, + "电脑仓+防盗拉链,通勤差旅。", + Spec(("电脑仓", "15.6 寸"), ("容量", "约 22L"), ("材质", "防泼水尼龙"), + ("重量", "约 0.8kg")), + ProductStatus.Active, 65)); + + list.Add(P( + "SKU-FOO-001", "三只松鼠坚果大礼包 1.5kg", "FOO-SNACK", + 128m, 260, "三只松鼠", 156_800, + "混合坚果礼盒,节日送礼演示。", + Spec(("净含量", "1.5kg"), ("类型", "混合坚果"), ("包装", "礼盒"), + ("保质期", "8 个月")), + ProductStatus.Active, 13)); + + list.Add(P( + "SKU-FOO-002", "农夫山泉 550ml×24 箱装", "FOO-DRINK", + 39.90m, 500, "农夫山泉", 320_000, + "天然饮用水整箱。", + Spec(("规格", "550ml×24"), ("类型", "天然水"), ("包装", "纸箱")), + ProductStatus.Active, 29)); + + list.Add(P( + "SKU-FOO-003", "智利车厘子 JJ 级 2.5kg 礼盒", "FOO-FRESH", + 199m, 80, "百果园", 41_200, + "生鲜水果演示(库存敏感品类)。", + Spec(("规格", "JJ 级"), ("净重", "2.5kg"), ("产地", "智利"), + ("储存", "冷藏")), + ProductStatus.Active, 45)); + + list.Add(P( + "SKU-BEA-001", "薇诺娜舒敏保湿特护霜 50g", "BEA-SKIN", + 268m, 150, "薇诺娜", 92_400, + "敏感肌护肤演示爆款。", + Spec(("净含量", "50g"), ("功效", "舒敏保湿"), ("肤质", "敏感肌"), + ("质地", "乳霜")), + ProductStatus.Active, 19)); + + list.Add(P( + "SKU-BEA-002", "完美日记动物眼影盘 14 色", "BEA-MAKEUP", + 99m, 220, "完美日记", 118_700, + "彩妆盘,适合美妆列表展示。", + Spec(("色号", "14 色"), ("质地", "粉质细腻"), ("包装", "便携盘")), + ProductStatus.Active, 35)); + + list.Add(P( + "SKU-BEA-003", "潘婷修护洗发水 750ml", "BEA-HAIR", + 49.90m, 400, "潘婷", 210_500, + "洗护走量单品。", + Spec(("净含量", "750ml"), ("功效", "修护损伤"), ("适用", "干枯受损发质")), + ProductStatus.Active, 51)); + + return list.ToArray(); + } + + private static ProductSeed P( + string sku, + string name, + string categoryCode, + decimal price, + int stock, + string brand, + int sales, + string description, + string specsJson, + string status, + byte hue) => + new(sku, name, categoryCode, price, stock, description, status, brand, sales, specsJson, hue); + + private static string Spec(params (string Key, string Value)[] pairs) + { + var dict = new Dictionary(pairs.Length); + foreach (var (key, value) in pairs) + { + dict[key] = value; + } + + return JsonSerializer.Serialize(dict, SpecJsonOptions); + } +} diff --git a/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedCoverPng.cs b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedCoverPng.cs new file mode 100644 index 0000000..da3cb46 --- /dev/null +++ b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedCoverPng.cs @@ -0,0 +1,227 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; + +namespace EShop.Modules.Catalog.Application.Seeding; + +/// +/// 演示封面图生成器:输出自包含 PNG(不依赖 System.Drawing)。 +/// 用于 Catalog Seed 的本地受管图片,禁止引用外域电商原图。 +/// +internal static class CatalogSeedCoverPng +{ + private static readonly uint[] CrcTable = CreateCrcTable(); + + /// 生成 640×640 影棚风纯色封面,底部带商品简称条。 + public static byte[] Create(string sku, string title, byte hue) + { + const int width = 640; + const int height = 640; + var (r, g, b) = HueToRgb(hue); + var (dr, dg, db) = ((byte)(r * 0.72), (byte)(g * 0.72), (byte)(b * 0.72)); + var (lr, lg, lb) = ( + (byte)Math.Min(255, r + 28), + (byte)Math.Min(255, g + 28), + (byte)Math.Min(255, b + 28)); + + // 每行 filter0 + RGB + var raw = new byte[(width * 3 + 1) * height]; + for (var y = 0; y < height; y++) + { + var row = y * (width * 3 + 1); + raw[row] = 0; // None filter + for (var x = 0; x < width; x++) + { + var px = row + 1 + x * 3; + // 中央略亮的「主体」矩形 + 下方深色接触阴影带 + var inBody = x is >= 160 and < 480 && y is >= 110 and < 470; + var inShadow = x is >= 180 and < 460 && y is >= 470 and < 500; + var inBand = y is >= 540 and < 600; + + if (inShadow) + { + raw[px] = (byte)(dr * 0.55); + raw[px + 1] = (byte)(dg * 0.55); + raw[px + 2] = (byte)(db * 0.55); + } + else if (inBody) + { + // 简单纵向渐变,避免整块死色 + var t = (y - 110) / 360.0; + raw[px] = (byte)(lr * (1 - t) + r * t); + raw[px + 1] = (byte)(lg * (1 - t) + g * t); + raw[px + 2] = (byte)(lb * (1 - t) + b * t); + } + else if (inBand) + { + raw[px] = 32; + raw[px + 1] = 40; + raw[px + 2] = 52; + } + else + { + // 影棚浅灰背景 + raw[px] = 244; + raw[px + 1] = 246; + raw[px + 2] = 248; + } + } + } + + // 标题与 SKU 以确定性噪点条编码进底部色带(不依赖字体栅格) + PaintLabelFingerprint(raw, width, height, title + "|" + sku); + + using var ms = new MemoryStream(); + // PNG signature + ms.Write([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + + Span ihdr = stackalloc byte[13]; + BinaryPrimitives.WriteUInt32BigEndian(ihdr[..4], (uint)width); + BinaryPrimitives.WriteUInt32BigEndian(ihdr.Slice(4, 4), (uint)height); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // RGB + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + WriteChunk(ms, "IHDR"u8, ihdr); + + var compressed = ZlibCompress(raw); + WriteChunk(ms, "IDAT"u8, compressed); + WriteChunk(ms, "IEND"u8, ReadOnlySpan.Empty); + return ms.ToArray(); + } + + public static string ContentHash(byte[] bytes) => + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + + private static void PaintLabelFingerprint(byte[] raw, int width, int height, string text) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(text)); + const int bandTop = 548; + const int bandBottom = 592; + for (var i = 0; i < hash.Length; i++) + { + var x0 = 40 + (i * 18) % (width - 80); + var y0 = bandTop + (hash[i] % (bandBottom - bandTop)); + for (var dy = 0; dy < 6; dy++) + { + for (var dx = 0; dx < 10; dx++) + { + var x = x0 + dx; + var y = y0 + dy; + if ((uint)x >= (uint)width || (uint)y >= (uint)height) + { + continue; + } + + var row = y * (width * 3 + 1); + var px = row + 1 + x * 3; + raw[px] = (byte)(220 - (hash[i] % 40)); + raw[px + 1] = (byte)(230 - (hash[(i + 1) % hash.Length] % 30)); + raw[px + 2] = 255; + } + } + } + } + + private static (byte R, byte G, byte B) HueToRgb(byte hue) + { + // hue 0..255 → soft product palette + var h = hue / 255.0 * 6.0; + var c = 0.42; + var x = c * (1 - Math.Abs(h % 2 - 1)); + double r1 = 0, g1 = 0, b1 = 0; + if (h < 1) { r1 = c; g1 = x; } + else if (h < 2) { r1 = x; g1 = c; } + else if (h < 3) { g1 = c; b1 = x; } + else if (h < 4) { g1 = x; b1 = c; } + else if (h < 5) { r1 = x; b1 = c; } + else { r1 = c; b1 = x; } + + const double m = 0.28; + return ( + (byte)((r1 + m) * 255), + (byte)((g1 + m) * 255), + (byte)((b1 + m) * 255)); + } + + private static byte[] ZlibCompress(byte[] raw) + { + using var output = new MemoryStream(); + // zlib header + output.WriteByte(0x78); + output.WriteByte(0x01); + using (var deflate = new DeflateStream(output, CompressionLevel.Optimal, leaveOpen: true)) + { + deflate.Write(raw); + } + + // Adler-32 of uncompressed data + var adler = Adler32(raw); + Span checksum = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(checksum, adler); + output.Write(checksum); + return output.ToArray(); + } + + private static uint Adler32(ReadOnlySpan data) + { + const uint mod = 65521; + uint a = 1, b = 0; + foreach (var value in data) + { + a = (a + value) % mod; + b = (b + a) % mod; + } + + return (b << 16) | a; + } + + private static void WriteChunk(Stream stream, ReadOnlySpan type, ReadOnlySpan data) + { + Span length = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(length, (uint)data.Length); + stream.Write(length); + stream.Write(type); + stream.Write(data); + + var crc = Crc32(type, data); + Span crcBytes = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(crcBytes, crc); + stream.Write(crcBytes); + } + + private static uint Crc32(ReadOnlySpan type, ReadOnlySpan data) + { + uint crc = 0xFFFFFFFF; + foreach (var value in type) + { + crc = CrcTable[(crc ^ value) & 0xFF] ^ (crc >> 8); + } + + foreach (var value in data) + { + crc = CrcTable[(crc ^ value) & 0xFF] ^ (crc >> 8); + } + + return crc ^ 0xFFFFFFFF; + } + + private static uint[] CreateCrcTable() + { + var table = new uint[256]; + for (uint n = 0; n < 256; n++) + { + var c = n; + for (var k = 0; k < 8; k++) + { + c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1; + } + + table[n] = c; + } + + return table; + } +} diff --git a/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs new file mode 100644 index 0000000..f286f4f --- /dev/null +++ b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs @@ -0,0 +1,721 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace EShop.Modules.Catalog.Application.Seeding; + +/// +/// Loads the reviewed, local-only demo artifact from mi-catalog.json and media/{sku}. +/// Production callers use requireMaterial=true so missing or malformed input fails closed. +/// +internal static class CatalogSeedMaterialLoader +{ + internal const int MaxImagesPerProduct = 120; + internal const int MaxImageBytes = 8 * 1024 * 1024; + internal const long MaxArtifactBytes = 1_100_000_000; + private const int MaxSpecificationsJsonLength = 8000; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + internal sealed class MiCatalogFile + { + public int? Count { get; set; } + public List Products { get; set; } = []; + } + + private sealed class ReviewedArtifactMetadata + { + public int FormatVersion { get; set; } + public int ProductCount { get; set; } + public int ImageCount { get; set; } + public long ImageBytes { get; set; } + public string CatalogSha256 { get; set; } = ""; + public string ChecksumManifestSha256 { get; set; } = ""; + } + + internal sealed class MiProduct + { + public int ProductId { get; set; } + public string Sku { get; set; } = ""; + public string Name { get; set; } = ""; + public decimal UnitPrice { get; set; } + public string? Description { get; set; } + public string? Brand { get; set; } + public int SalesCount { get; set; } + public Dictionary? Specifications { get; set; } + public List? ParameterList { get; set; } + public List? ImageFiles { get; set; } + public string? CoverFile { get; set; } + + [JsonIgnore] + internal Dictionary? ValidatedImageHashes { get; set; } + } + + internal sealed class MiParameter + { + public string Name { get; set; } = ""; + public string Value { get; set; } = ""; + public bool Highlight { get; set; } + } + + internal static string? ResolveSeedDirectory( + string? contentRootPath, + string? configuredMaterialRoot = null) + { + if (!string.IsNullOrWhiteSpace(configuredMaterialRoot)) + { + return Path.GetFullPath(configuredMaterialRoot); + } + + if (string.IsNullOrWhiteSpace(contentRootPath)) + { + return null; + } + + return Path.GetFullPath(Path.Combine(contentRootPath, "scripts", "seed")); + } + + internal static IReadOnlyList LoadMiProducts( + string? seedDirectory, + bool requireMaterial = false, + bool requireReviewedArtifact = false) + { + requireMaterial |= requireReviewedArtifact; + if (seedDirectory is null) + { + if (requireMaterial) + { + throw new InvalidOperationException("未配置 Catalog Seed 素材目录。"); + } + + return []; + } + + var catalogPath = Path.Combine(seedDirectory, "mi-catalog.json"); + if (!File.Exists(catalogPath)) + { + if (requireMaterial) + { + throw new InvalidOperationException($"Catalog Seed 素材清单不存在:{catalogPath}"); + } + + return []; + } + + if (requireReviewedArtifact) + { + EnsureNoReparsePoint(seedDirectory, catalogPath); + } + + MiCatalogFile file; + try + { + var json = File.ReadAllText(catalogPath); + file = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException("mi-catalog.json 反序列化结果为空。"); + } + catch (Exception exception) when (exception is JsonException or IOException) + { + if (!requireMaterial) + { + return []; + } + + throw new InvalidOperationException( + $"Catalog Seed 素材清单无法读取:{catalogPath}", + exception); + } + + var products = file.Products; + if (products.Count == 0) + { + if (requireMaterial) + { + throw new InvalidOperationException("mi-catalog.json 未包含任何商品。"); + } + + return []; + } + + var seenSkus = new HashSet(StringComparer.Ordinal); + foreach (var product in products) + { + product.Sku = product.Sku.Trim(); + product.Name = product.Name.Trim(); + if (!IsSafeSku(product.Sku)) + { + throw new InvalidOperationException($"Seed SKU 非法:{product.Sku}"); + } + + if (product.Name.Length is < 1 or > 100) + { + throw new InvalidOperationException($"Seed 商品名称长度非法:{product.Sku}"); + } + + if (product.UnitPrice <= 0 || decimal.Round(product.UnitPrice, 2) != product.UnitPrice) + { + throw new InvalidOperationException($"Seed 商品价格非法:{product.Sku}"); + } + + if (product.SalesCount < 0) + { + throw new InvalidOperationException($"Seed 商品销量非法:{product.Sku}"); + } + + if (!seenSkus.Add(product.Sku)) + { + throw new InvalidOperationException($"mi-catalog.json 存在重复 SKU:{product.Sku}"); + } + + if (requireMaterial && product.ImageFiles is not { Count: > 0 }) + { + throw new InvalidOperationException($"Seed 商品缺少 imageFiles:{product.Sku}"); + } + + if (product.ImageFiles is { Count: > MaxImagesPerProduct }) + { + throw new InvalidOperationException( + $"商品 {product.Sku} 图片数量 {product.ImageFiles.Count} 超过上限 {MaxImagesPerProduct}。"); + } + } + + if (requireReviewedArtifact) + { + if (file.Count != products.Count) + { + throw new InvalidOperationException( + $"mi-catalog.json count 与商品数量不一致:{file.Count?.ToString() ?? "null"}/{products.Count}"); + } + + ValidateReviewedArtifact(seedDirectory, catalogPath, products); + } + + return products; + } + + /// + /// Resolves exact image paths declared by the reviewed manifest. Every declared image must + /// exist, remain under media/{sku}, be unique, and use a supported extension. + /// + internal static IReadOnlyList ResolveImagePaths( + string? seedDirectory, + string sku, + MiProduct? mi, + bool requireListedMaterial = false, + bool requireReviewedArtifact = false) + { + if (seedDirectory is null) + { + if (requireListedMaterial && mi is not null) + { + throw new InvalidOperationException($"商品 {sku} 未配置 Seed 素材目录。"); + } + + return []; + } + + if (!IsSafeSku(sku)) + { + throw new InvalidOperationException($"Seed SKU 非法:{sku}"); + } + + var mediaSkuDir = Path.GetFullPath(Path.Combine(seedDirectory, "media", sku)); + EnsureContained(seedDirectory, mediaSkuDir); + + if (requireReviewedArtifact && mi is null) + { + return []; + } + + if (mi?.ImageFiles is { Count: > 0 } declaredImages) + { + if (declaredImages.Count > MaxImagesPerProduct) + { + throw new InvalidOperationException( + $"商品 {sku} 图片数量 {declaredImages.Count} 超过上限 {MaxImagesPerProduct}。"); + } + + var result = new List(declaredImages.Count); + var seen = new HashSet( + OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + foreach (var declared in declaredImages) + { + var path = ResolveDeclaredImage(seedDirectory, mediaSkuDir, declared, sku); + if (!seen.Add(path)) + { + throw new InvalidOperationException($"商品 {sku} 重复引用图片:{declared}"); + } + + result.Add(path); + } + + return result; + } + + if (requireListedMaterial && mi is not null) + { + throw new InvalidOperationException($"商品 {sku} 的 imageFiles 为空。"); + } + + if (Directory.Exists(mediaSkuDir)) + { + var ordered = new List(); + ordered.AddRange(EnumerateImages(seedDirectory, Path.Combine(mediaSkuDir, "gallery"))); + ordered.AddRange(EnumerateImages(seedDirectory, Path.Combine(mediaSkuDir, "overview"))); + if (ordered.Count == 0) + { + ordered.AddRange(EnumerateImages(seedDirectory, mediaSkuDir)); + } + + return ordered.Take(MaxImagesPerProduct).ToList(); + } + + var coversDir = Path.GetFullPath(Path.Combine(seedDirectory, "covers")); + EnsureContained(seedDirectory, coversDir); + if (Directory.Exists(coversDir)) + { + EnsureNoReparsePoint(seedDirectory, coversDir); + if (!string.IsNullOrWhiteSpace(mi?.CoverFile)) + { + var preferred = ResolveDeclaredImage(seedDirectory, coversDir, mi.CoverFile, sku); + return [preferred]; + } + + var legacy = Directory.EnumerateFiles(coversDir, sku + ".*") + .Where(IsSupportedImagePath) + .Select(path => EnsureNoReparsePointAndReturn(seedDirectory, path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .Take(1) + .ToList(); + if (legacy.Count > 0) + { + return legacy; + } + } + + return []; + } + + private static string ResolveDeclaredImage( + string containmentRoot, + string allowedRoot, + string relativePath, + string sku) + { + var normalized = NormalizeDeclaredImagePath(relativePath, sku); + var segments = normalized.Split('/'); + var path = Path.GetFullPath(Path.Combine([allowedRoot, .. segments])); + EnsureContained(allowedRoot, path); + if (!IsSupportedImagePath(path)) + { + throw new InvalidOperationException($"商品 {sku} 图片格式不受支持:{relativePath}"); + } + + if (!File.Exists(path)) + { + throw new InvalidOperationException($"商品 {sku} 图片不存在:{relativePath}"); + } + + EnsureNoReparsePoint(containmentRoot, path); + return path; + } + + private static IEnumerable EnumerateImages(string containmentRoot, string directory) + { + if (!Directory.Exists(directory)) + { + return []; + } + + EnsureNoReparsePoint(containmentRoot, directory); + return Directory.EnumerateFiles(directory) + .Where(IsSupportedImagePath) + .Select(path => EnsureNoReparsePointAndReturn(containmentRoot, path)) + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase); + } + + private static string NormalizeDeclaredImagePath(string relativePath, string sku) + { + if (string.IsNullOrWhiteSpace(relativePath) + || Path.IsPathRooted(relativePath) + || relativePath.Contains(':', StringComparison.Ordinal)) + { + throw new InvalidOperationException($"商品 {sku} 图片路径非法:{relativePath}"); + } + + var segments = relativePath + .Replace('\\', '/') + .Split('/', StringSplitOptions.None); + if (segments.Length == 0 + || segments.Any(segment => string.IsNullOrEmpty(segment) || segment is "." or "..")) + { + throw new InvalidOperationException($"商品 {sku} 图片路径非法:{relativePath}"); + } + + var normalized = string.Join('/', segments); + if (!IsSupportedImagePath(normalized)) + { + throw new InvalidOperationException($"商品 {sku} 图片格式不受支持:{relativePath}"); + } + + return normalized; + } + + private static bool IsSupportedImagePath(string path) + { + var extension = Path.GetExtension(path).ToLowerInvariant(); + return extension is ".png" or ".jpg" or ".jpeg" or ".webp"; + } + + private static void EnsureContained(string root, string candidate) + { + var rootFull = Path.GetFullPath(root) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + var candidateFull = Path.GetFullPath(candidate); + var comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!candidateFull.StartsWith(rootFull, comparison) + && !string.Equals( + candidateFull.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + rootFull.TrimEnd(Path.DirectorySeparatorChar), + comparison)) + { + throw new InvalidOperationException($"Seed 路径越界:{candidate}"); + } + } + + private static void EnsureNoReparsePoint(string root, string filePath) + { + var relative = Path.GetRelativePath(root, filePath); + var current = Path.GetFullPath(root); + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException($"Seed 路径包含符号链接或重解析点:{filePath}"); + } + + foreach (var segment in relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + current = Path.Combine(current, segment); + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException($"Seed 路径包含符号链接或重解析点:{filePath}"); + } + } + } + + private static string EnsureNoReparsePointAndReturn(string root, string path) + { + EnsureNoReparsePoint(root, path); + return path; + } + + private static void ValidateReviewedArtifact( + string seedDirectory, + string catalogPath, + IReadOnlyCollection products) + { + var metadataPath = Path.Combine(seedDirectory, "ARTIFACT.json"); + var manifestPath = Path.Combine(seedDirectory, "SHA256SUMS"); + if (!File.Exists(metadataPath) || !File.Exists(manifestPath)) + { + throw new InvalidOperationException("Catalog Seed 缺少 ARTIFACT.json 或 SHA256SUMS。 "); + } + + EnsureNoReparsePoint(seedDirectory, metadataPath); + EnsureNoReparsePoint(seedDirectory, manifestPath); + var manifestBytes = File.ReadAllBytes(manifestPath); + ReviewedArtifactMetadata metadata; + try + { + metadata = JsonSerializer.Deserialize( + File.ReadAllText(metadataPath), + JsonOptions) + ?? throw new InvalidOperationException("ARTIFACT.json 反序列化结果为空。"); + } + catch (Exception exception) when (exception is JsonException or IOException) + { + throw new InvalidOperationException("Catalog Seed ARTIFACT.json 无法读取。", exception); + } + + if (metadata.FormatVersion != 1 + || metadata.ProductCount != products.Count + || !IsSha256(metadata.CatalogSha256) + || !IsSha256(metadata.ChecksumManifestSha256)) + { + throw new InvalidOperationException("Catalog Seed ARTIFACT.json 元数据非法。"); + } + + var catalogHash = ComputeSha256(catalogPath); + var manifestHash = Convert.ToHexString(SHA256.HashData(manifestBytes)).ToLowerInvariant(); + if (!string.Equals(metadata.CatalogSha256, catalogHash, StringComparison.Ordinal) + || !string.Equals(metadata.ChecksumManifestSha256, manifestHash, StringComparison.Ordinal)) + { + throw new InvalidOperationException("Catalog Seed 制品摘要与 ARTIFACT.json 不一致。"); + } + + var comparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + var expectedPaths = new HashSet(comparer) { "mi-catalog.json" }; + var imageOwnerByPath = new Dictionary(comparer); + foreach (var product in products) + { + foreach (var declared in product.ImageFiles ?? []) + { + var normalized = NormalizeDeclaredImagePath(declared, product.Sku); + var expected = $"media/{product.Sku}/{normalized}"; + if (!expectedPaths.Add(expected)) + { + throw new InvalidOperationException($"Catalog Seed 重复声明制品路径:{expected}"); + } + + imageOwnerByPath.Add(expected, product); + } + } + + var actualPaths = new HashSet(comparer); + long imageBytes = 0; + var manifestText = Encoding.UTF8.GetString(manifestBytes); + if (manifestText.Contains('\r', StringComparison.Ordinal)) + { + throw new InvalidOperationException("Catalog Seed SHA256SUMS 必须使用 UTF-8 LF。 "); + } + + foreach (var line in manifestText.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + if (line.Length <= 66 || line[64] != ' ' || line[65] != ' ') + { + throw new InvalidOperationException("Catalog Seed SHA256SUMS 行格式非法。"); + } + + var expectedHash = line[..64]; + var relativePath = line[66..]; + if (!IsSha256(expectedHash) + || relativePath.Contains('\\', StringComparison.Ordinal) + || !expectedPaths.Contains(relativePath) + || !actualPaths.Add(relativePath)) + { + throw new InvalidOperationException($"Catalog Seed SHA256SUMS 路径或摘要非法:{relativePath}"); + } + + var segments = relativePath.Split('/'); + if (segments.Any(segment => string.IsNullOrEmpty(segment) || segment is "." or "..")) + { + throw new InvalidOperationException($"Catalog Seed SHA256SUMS 路径非法:{relativePath}"); + } + + var filePath = Path.GetFullPath(Path.Combine([seedDirectory, .. segments])); + EnsureContained(seedDirectory, filePath); + if (!File.Exists(filePath)) + { + throw new InvalidOperationException($"Catalog Seed 制品文件不存在:{relativePath}"); + } + + EnsureNoReparsePoint(seedDirectory, filePath); + var actualHash = ComputeSha256(filePath); + if (!string.Equals(expectedHash, actualHash, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Catalog Seed 制品文件摘要不一致:{relativePath}"); + } + + if (!string.Equals(relativePath, "mi-catalog.json", StringComparison.Ordinal)) + { + var fileLength = new FileInfo(filePath).Length; + if (fileLength is <= 0 or > MaxImageBytes) + { + throw new InvalidOperationException( + $"Catalog Seed 图片大小非法(1..{MaxImageBytes} bytes):{relativePath}"); + } + + var detectedExtension = DetectImageExtension(filePath); + var declaredExtension = Path.GetExtension(filePath).ToLowerInvariant(); + var normalizedDeclaredExtension = declaredExtension == ".jpeg" ? ".jpg" : declaredExtension; + if (!string.Equals(detectedExtension, normalizedDeclaredExtension, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Catalog Seed 图片扩展名与魔数不一致:{relativePath}"); + } + + imageBytes = checked(imageBytes + fileLength); + if (imageBytes > MaxArtifactBytes) + { + throw new InvalidOperationException( + $"Catalog Seed 图片总量超过 {MaxArtifactBytes} bytes。"); + } + + var owner = imageOwnerByPath[relativePath]; + owner.ValidatedImageHashes ??= new Dictionary(comparer); + owner.ValidatedImageHashes.Add(filePath, expectedHash); + } + } + + if (!actualPaths.SetEquals(expectedPaths) + || metadata.ImageCount != expectedPaths.Count - 1 + || metadata.ImageBytes != imageBytes) + { + throw new InvalidOperationException("Catalog Seed 制品文件集合或计数与 ARTIFACT.json 不一致。"); + } + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + private static bool IsSha256(string value) + => value.Length == 64 && value.All(character => character is >= '0' and <= '9' or >= 'a' and <= 'f'); + + internal static void VerifyReviewedImageHash( + MiProduct? product, + string path, + byte[] bytes, + bool required) + { + if (!required) + { + return; + } + + var fullPath = Path.GetFullPath(path); + if (product?.ValidatedImageHashes is null + || !product.ValidatedImageHashes.TryGetValue(fullPath, out var expectedHash)) + { + throw new InvalidOperationException($"Catalog Seed 图片未进入受审摘要清单:{path}"); + } + + var actualHash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + if (!string.Equals(expectedHash, actualHash, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Catalog Seed 图片在预检后发生变化:{path}"); + } + } + + private static string? DetectImageExtension(string path) + { + Span header = stackalloc byte[12]; + using var stream = File.OpenRead(path); + var read = stream.Read(header); + if (read >= 3 && header[0] == 0xff && header[1] == 0xd8 && header[2] == 0xff) + { + return ".jpg"; + } + + if (read >= 8 + && header[0] == 0x89 + && header[1] == (byte)'P' + && header[2] == (byte)'N' + && header[3] == (byte)'G' + && header[4] == 0x0d + && header[5] == 0x0a + && header[6] == 0x1a + && header[7] == 0x0a) + { + return ".png"; + } + + if (read >= 12 + && header[..4].SequenceEqual("RIFF"u8) + && header.Slice(8, 4).SequenceEqual("WEBP"u8)) + { + return ".webp"; + } + + return null; + } + + private static bool IsSafeSku(string sku) + { + if (sku.Length is < 1 or > 64) + { + return false; + } + + return sku.All(character => + character is >= 'A' and <= 'Z' + or >= '0' and <= '9' + or '-'); + } + + internal static string InferCategoryCode(string sku) + { + if (sku.StartsWith("SKU-PHONE", StringComparison.Ordinal)) return "DIG-PHONE-SMART"; + if (sku.StartsWith("SKU-PAD", StringComparison.Ordinal)) return "DIG-PAD-ANDROID"; + if (sku.StartsWith("SKU-WEAR-00", StringComparison.Ordinal)) + { + return sku is "SKU-WEAR-001" or "SKU-WEAR-002" ? "DIG-WEAR-BAND" : "DIG-WEAR-WATCH"; + } + + if (sku.StartsWith("SKU-AUDIO", StringComparison.Ordinal)) return "DIG-AUDIO"; + if (sku.StartsWith("SKU-NOTE", StringComparison.Ordinal)) return "DIG-NOTE"; + if (sku.StartsWith("SKU-TV", StringComparison.Ordinal)) return "DIG-TV"; + if (sku.StartsWith("SKU-NET", StringComparison.Ordinal)) return "DIG-NET"; + if (sku.StartsWith("SKU-HOM-AC", StringComparison.Ordinal) + || sku.StartsWith("SKU-HOM-RF", StringComparison.Ordinal) + || sku.StartsWith("SKU-HOM-WM", StringComparison.Ordinal)) + { + return "HOM-CLIMATE"; + } + + if (sku.StartsWith("SKU-DIG-003", StringComparison.Ordinal)) return "DIG-ACC-CHARGER"; + if (sku.StartsWith("SKU-DIG", StringComparison.Ordinal)) return "DIG-ACC-CABLE"; + throw new InvalidOperationException($"无法为 Seed SKU 推断分类:{sku}"); + } + + internal static string SpecsToJson(MiProduct mi) + { + var dict = new Dictionary(StringComparer.Ordinal); + if (mi.ParameterList is { Count: > 0 }) + { + foreach (var row in mi.ParameterList) + { + if (!string.IsNullOrWhiteSpace(row.Name) && !string.IsNullOrWhiteSpace(row.Value)) + { + dict[row.Name.Trim()] = row.Value.Trim(); + } + } + } + + if (mi.Specifications is { Count: > 0 }) + { + foreach (var (key, value) in mi.Specifications) + { + if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value)) + { + dict.TryAdd(key.Trim(), value.Trim()); + } + } + } + + if (dict.Count == 0) + { + dict["来源"] = "演示种子"; + } + + var serializerOptions = new JsonSerializerOptions + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + var json = JsonSerializer.Serialize(dict, serializerOptions); + while (dict.Count > 1 && json.Length > MaxSpecificationsJsonLength) + { + dict.Remove(dict.Keys.Last()); + json = JsonSerializer.Serialize(dict, serializerOptions); + } + + return json; + } + + internal static string SpecsToJson(Dictionary? specs) + => SpecsToJson(new MiProduct { Specifications = specs }); +} diff --git a/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs index fffc2f8..91bd3a7 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; using EShop.Modules.Catalog.Application.Seeding; using EShop.Modules.Catalog.Domain; using Microsoft.EntityFrameworkCore; @@ -7,16 +10,25 @@ namespace EShop.Tests.Unit.Modules.Catalog; public sealed class CatalogDataSeederTests { [Fact] - public async Task First_run_creates_expected_catalog_baseline_without_fake_images() + public async Task First_run_creates_expected_catalog_baseline_without_images_when_webroot_absent() { await using var db = CreateDbContext(); var seeder = new CatalogDataSeeder(db); await seeder.SeedAsync(); - Assert.Equal(36, await db.Set().CountAsync()); - Assert.Equal(30, await db.Set().CountAsync()); + var expectedCategories = CatalogDemoProductCatalog.Categories.Length; + var expectedProducts = CatalogDemoProductCatalog.Products.Length; + + Assert.Equal(expectedCategories, await db.Set().CountAsync()); + Assert.Equal(expectedProducts, await db.Set().CountAsync()); Assert.Empty(await db.Set().ToListAsync()); + Assert.True(expectedProducts >= 30); + + var flagship = await ProductBySkuAsync(db, "SKU-PHONE-001"); + Assert.Equal("Xiaomi", flagship.Brand); + Assert.True(flagship.SalesCount > 0); + Assert.Contains("屏幕", flagship.SpecificationsJson); var lowStock = await ProductBySkuAsync(db, "SKU-DIG-007"); Assert.Equal(2, lowStock.StockQuantity); @@ -32,6 +44,8 @@ public sealed class CatalogDataSeederTests Assert.Equal( ProductStatus.Inactive, (await ProductBySkuAsync(db, "SKU-HOM-009")).Status); + + Assert.Contains("%", (await ProductBySkuAsync(db, "SKU-HOM-001")).Name); } [Fact] @@ -57,8 +71,8 @@ public sealed class CatalogDataSeederTests await seeder.SeedAsync(); - Assert.Equal(36, await db.Set().CountAsync()); - Assert.Equal(30, await db.Set().CountAsync()); + Assert.Equal(CatalogDemoProductCatalog.Categories.Length, await db.Set().CountAsync()); + Assert.Equal(CatalogDemoProductCatalog.Products.Length, await db.Set().CountAsync()); Assert.Empty(await db.Set().ToListAsync()); retained = await ProductBySkuAsync(db, "SKU-DIG-001"); @@ -70,6 +84,331 @@ public sealed class CatalogDataSeederTests (await ProductBySkuAsync(db, "SKU-BOK-010")).Name); } + [Fact] + public async Task With_webroot_writes_local_cover_images_for_seed_products() + { + await using var db = CreateDbContext(); + var webRoot = Path.Combine(Path.GetTempPath(), "eshop-seed-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(webRoot); + try + { + var seeder = new CatalogDataSeeder(db, webRoot, contentRootPath: null); + await seeder.SeedAsync(); + + var productCount = CatalogDemoProductCatalog.Products.Length; + Assert.Equal(productCount, await db.Set().CountAsync()); + Assert.Equal(productCount, await db.Set().CountAsync()); + + var cover = await db.Set().AsNoTracking().FirstAsync(i => i.IsCover); + Assert.StartsWith("uploads/products/seed/", cover.StorageKey); + Assert.True(cover.SizeBytes > 100); + Assert.Equal("/" + cover.StorageKey, cover.PublicUrl); + + var absolute = Path.Combine(webRoot, cover.StorageKey.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(absolute)); + Assert.All(await db.Set().ToListAsync(), product => Assert.Equal(1, product.Version)); + + // 幂等:不重复插封面 + await seeder.SeedAsync(); + Assert.Equal(productCount, await db.Set().CountAsync()); + } + finally + { + if (Directory.Exists(webRoot)) + { + Directory.Delete(webRoot, recursive: true); + } + } + } + + [Fact] + public async Task Required_material_fails_closed_when_manifest_is_missing() + { + await using var db = CreateDbContext(); + var root = Path.Combine(Path.GetTempPath(), "eshop-seed-missing-" + Guid.NewGuid().ToString("N")); + var webRoot = Path.Combine(root, "wwwroot"); + var materialRoot = Path.Combine(root, "material"); + Directory.CreateDirectory(webRoot); + Directory.CreateDirectory(materialRoot); + try + { + var seeder = new CatalogDataSeeder( + db, + webRoot, + contentRootPath: root, + configuredMaterialRoot: materialRoot, + requireMaterial: true); + + var error = await Assert.ThrowsAsync(() => seeder.SeedAsync()); + + Assert.Contains("mi-catalog.json", error.Message, StringComparison.Ordinal); + Assert.Empty(Directory.EnumerateFiles(webRoot, "*", SearchOption.AllDirectories)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task Required_material_rejects_path_traversal() + { + await using var db = CreateDbContext(); + var root = Path.Combine(Path.GetTempPath(), "eshop-seed-traversal-" + Guid.NewGuid().ToString("N")); + var webRoot = Path.Combine(root, "wwwroot"); + var materialRoot = Path.Combine(root, "material"); + Directory.CreateDirectory(Path.Combine(materialRoot, "media", "SKU-PHONE-001")); + Directory.CreateDirectory(webRoot); + await File.WriteAllBytesAsync(Path.Combine(materialRoot, "media", "secret.png"), [0x89, 0x50]); + await File.WriteAllTextAsync( + Path.Combine(materialRoot, "mi-catalog.json"), + """ + { "products": [{ + "sku": "SKU-PHONE-001", "name": "路径测试", "unitPrice": 1, + "imageFiles": ["../secret.png"] + }] } + """); + try + { + var seeder = new CatalogDataSeeder( + db, + webRoot, + root, + materialRoot, + requireMaterial: true); + + var error = await Assert.ThrowsAsync(() => seeder.SeedAsync()); + + Assert.Contains("路径非法", error.Message, StringComparison.Ordinal); + Assert.Empty(Directory.EnumerateFiles(webRoot, "*", SearchOption.AllDirectories)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task Required_material_rejects_extension_magic_mismatch_and_cleans_outputs() + { + await using var db = CreateDbContext(); + var root = Path.Combine(Path.GetTempPath(), "eshop-seed-magic-" + Guid.NewGuid().ToString("N")); + var webRoot = Path.Combine(root, "wwwroot"); + var materialRoot = Path.Combine(root, "material"); + var mediaRoot = Path.Combine(materialRoot, "media", "SKU-PHONE-001"); + Directory.CreateDirectory(mediaRoot); + Directory.CreateDirectory(webRoot); + await File.WriteAllBytesAsync( + Path.Combine(mediaRoot, "cover.jpg"), + "GIF89a"u8.ToArray()); + await File.WriteAllTextAsync( + Path.Combine(materialRoot, "mi-catalog.json"), + """ + { "products": [{ + "sku": "SKU-PHONE-001", "name": "魔数测试", "unitPrice": 1, + "imageFiles": ["cover.jpg"] + }] } + """); + try + { + var seeder = new CatalogDataSeeder( + db, + webRoot, + root, + materialRoot, + requireMaterial: true); + + var error = await Assert.ThrowsAsync(() => seeder.SeedAsync()); + + Assert.Contains("魔数", error.Message, StringComparison.Ordinal); + Assert.Empty(Directory.EnumerateFiles(webRoot, "*", SearchOption.AllDirectories)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task Reviewed_artifact_rejects_a_file_changed_after_manifest_generation() + { + var root = Path.Combine(Path.GetTempPath(), "eshop-reviewed-seed-" + Guid.NewGuid().ToString("N")); + var mediaRoot = Path.Combine(root, "media", "SKU-PHONE-001"); + Directory.CreateDirectory(mediaRoot); + try + { + var catalogBytes = Encoding.UTF8.GetBytes( + """ + { "count": 1, "products": [{ + "sku": "SKU-PHONE-001", "name": "摘要测试", "unitPrice": 1, + "imageFiles": ["cover.png"] + }] } + """); + var imageBytes = CatalogSeedCoverPng.Create("SKU-PHONE-001", "摘要测试", 1); + var catalogPath = Path.Combine(root, "mi-catalog.json"); + var imagePath = Path.Combine(mediaRoot, "cover.png"); + await File.WriteAllBytesAsync(catalogPath, catalogBytes); + await File.WriteAllBytesAsync(imagePath, imageBytes); + + var manifestBytes = Encoding.UTF8.GetBytes( + $"{Sha256(catalogBytes)} mi-catalog.json\n" + + $"{Sha256(imageBytes)} media/SKU-PHONE-001/cover.png\n"); + await File.WriteAllBytesAsync(Path.Combine(root, "SHA256SUMS"), manifestBytes); + await File.WriteAllTextAsync( + Path.Combine(root, "ARTIFACT.json"), + JsonSerializer.Serialize(new + { + formatVersion = 1, + productCount = 1, + imageCount = 1, + imageBytes = imageBytes.LongLength, + catalogSha256 = Sha256(catalogBytes), + checksumManifestSha256 = Sha256(manifestBytes), + })); + + var products = CatalogSeedMaterialLoader.LoadMiProducts( + root, + requireMaterial: true, + requireReviewedArtifact: true); + Assert.Single(products); + + imageBytes[^1] ^= 0xff; + await File.WriteAllBytesAsync(imagePath, imageBytes); + var resolvedPaths = CatalogSeedMaterialLoader.ResolveImagePaths( + root, + "SKU-PHONE-001", + products[0], + requireListedMaterial: true, + requireReviewedArtifact: true); + var changedBytes = await File.ReadAllBytesAsync(Assert.Single(resolvedPaths)); + var changedError = Assert.Throws(() => + CatalogSeedMaterialLoader.VerifyReviewedImageHash( + products[0], + imagePath, + changedBytes, + required: true)); + Assert.Contains("预检后发生变化", changedError.Message, StringComparison.Ordinal); + + var error = Assert.Throws(() => + CatalogSeedMaterialLoader.LoadMiProducts( + root, + requireMaterial: true, + requireReviewedArtifact: true)); + Assert.Contains("摘要不一致", error.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task With_mi_catalog_material_uses_gallery_images_parameters_and_appends_new_skus() + { + await using var db = CreateDbContext(); + var root = Path.Combine(Path.GetTempPath(), "eshop-mi-" + Guid.NewGuid().ToString("N")); + var contentRoot = Path.Combine(root, "backend", "src", "EShop.Api"); + var seedDir = Path.Combine(root, "scripts", "seed"); + var mediaSkuDir = Path.Combine(seedDir, "media", "SKU-PHONE-003"); + var webRoot = Path.Combine(contentRoot, "wwwroot"); + Directory.CreateDirectory(mediaSkuDir); + Directory.CreateDirectory(webRoot); + + // 1x1 PNG × 3(封面 + 两张详情) + var png = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + await File.WriteAllBytesAsync(Path.Combine(mediaSkuDir, "00-cover.png"), png); + await File.WriteAllBytesAsync(Path.Combine(mediaSkuDir, "01-gallery.png"), png); + await File.WriteAllBytesAsync(Path.Combine(mediaSkuDir, "02-detail.png"), png); + + var catalog = """ + { + "products": [ + { + "productId": 24930, + "sku": "SKU-PHONE-003", + "name": "REDMI Note 17 Pro 实测价", + "unitPrice": 1699, + "description": "抗摔 防水 长续航", + "brand": "REDMI", + "salesCount": 312400, + "parameterList": [ + { "name": "CPU型号", "value": "第四代骁龙6s", "highlight": true }, + { "name": "电池容量", "value": "9000mAh", "highlight": true } + ], + "specifications": { "版本": "12GB+256GB", "颜色": "黑色" }, + "imageFiles": [ "00-cover.png", "01-gallery.png", "02-detail.png" ], + "coverFile": "00-cover.png" + }, + { + "productId": 999001, + "sku": "SKU-PHONE-099", + "name": "仅存在于 mi-catalog 的新机", + "unitPrice": 999, + "description": "追加 SKU", + "brand": "Xiaomi", + "salesCount": 1000, + "specifications": { "来源": "test" }, + "imageFiles": [] + } + ] + } + """; + await File.WriteAllTextAsync(Path.Combine(seedDir, "mi-catalog.json"), catalog); + + try + { + var seeder = new CatalogDataSeeder( + db, + webRoot, + contentRoot, + configuredMaterialRoot: seedDir); + await seeder.SeedAsync(); + + var note = await ProductBySkuAsync(db, "SKU-PHONE-003"); + Assert.Equal("REDMI Note 17 Pro 实测价", note.Name); + Assert.Equal(1699m, note.UnitPrice); + Assert.Equal("REDMI", note.Brand); + Assert.Contains("CPU型号", note.SpecificationsJson); + Assert.Contains("9000mAh", note.SpecificationsJson); + + Assert.True(await db.Set().AnyAsync(p => p.Sku == "SKU-PHONE-099")); + + var images = await db.Set() + .AsNoTracking() + .Where(i => i.ProductId == note.Id) + .OrderBy(i => i.SortOrder) + .ToListAsync(); + Assert.Equal(3, images.Count); + Assert.True(images[0].IsCover); + Assert.False(images[1].IsCover); + Assert.Equal("image/png", images[0].MimeType); + Assert.Equal(png.LongLength, images[0].SizeBytes); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Fact] + public void Seed_cover_png_is_valid_png_signature() + { + var bytes = CatalogSeedCoverPng.Create("SKU-TEST", "演示商品", 42); + Assert.True(bytes.Length > 64); + Assert.Equal(0x89, bytes[0]); + Assert.Equal((byte)'P', bytes[1]); + Assert.Equal((byte)'N', bytes[2]); + Assert.Equal((byte)'G', bytes[3]); + Assert.False(string.IsNullOrEmpty(CatalogSeedCoverPng.ContentHash(bytes))); + } + + private static string Sha256(byte[] bytes) + => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); + private static CatalogSeedingDbContext CreateDbContext() { var options = new DbContextOptionsBuilder() diff --git a/frontend/package.json b/frontend/package.json index 6ca3fe2..11b28b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "packageManager": "pnpm@11.17.0", "engines": { "node": "24.14.0", - "pnpm": ">=11.17.0" + "pnpm": "11.17.0" }, "scripts": { "dev": "pnpm dev:web", @@ -17,8 +17,8 @@ "build:desktop": "vue-tsc -b --force && vite build --mode desktop", "preview:web": "vite preview --outDir dist/web", "lint": "eslint . --max-warnings=0", - "format": "prettier --write . ../contracts/openapi ../scripts/openapi ../scripts/verify", - "format:check": "prettier --check . ../contracts/openapi ../scripts/openapi ../scripts/verify", + "format": "prettier --write . ../contracts/openapi ../scripts/openapi ../scripts/verify ../scripts/seed/create-runtime-artifact.mjs", + "format:check": "prettier --check . ../contracts/openapi ../scripts/openapi ../scripts/verify ../scripts/seed/create-runtime-artifact.mjs", "typecheck": "vue-tsc -b --force", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/database/catalog-schema.sql b/scripts/database/catalog-schema.sql index b202299..beeeb65 100644 --- a/scripts/database/catalog-schema.sql +++ b/scripts/database/catalog-schema.sql @@ -128,7 +128,7 @@ CREATE INDEX ix_product_image_product ON catalog.product_image(product_id); COMMENT ON TABLE catalog.product_image IS '商品图片(本批仅数据模型/种子/读取,不含上传端点)'; -COMMENT ON COLUMN catalog.product_image.storage_key IS '对象存储键,全局唯一;种子数据固定 seed/{sku}'; +COMMENT ON COLUMN catalog.product_image.storage_key IS '对象存储键,全局唯一;当前种子格式 uploads/products/seed/{sha256}.{ext}'; COMMENT ON COLUMN catalog.product_image.size_bytes IS '文件字节数,> 0'; COMMENT ON COLUMN catalog.product_image.content_hash IS '内容 SHA-256 十六进制小写(64 字符)'; COMMENT ON COLUMN catalog.product_image.is_cover IS 'TRUE 时受部分唯一索引保护,每商品最多一张封面'; diff --git a/scripts/seed/.gitignore b/scripts/seed/.gitignore new file mode 100644 index 0000000..63cbc33 --- /dev/null +++ b/scripts/seed/.gitignore @@ -0,0 +1,11 @@ +# 接口原始响应体积大,本地调试用 +mi-raw/ +mi-manifest.json + +# 大体积图片只进入经哈希校验的外部发布制品,不进入普通 Git 历史 +covers/ +media/ +ARTIFACT.json +SHA256SUMS + +# mi-catalog.json 是受审清单;与 create-runtime-artifact.mjs 一同提交 diff --git a/scripts/seed/create-runtime-artifact.mjs b/scripts/seed/create-runtime-artifact.mjs new file mode 100644 index 0000000..32b7995 --- /dev/null +++ b/scripts/seed/create-runtime-artifact.mjs @@ -0,0 +1,217 @@ +import { createHash } from "node:crypto"; +import { cp, lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const MAX_IMAGES_PER_PRODUCT = 120; +const MAX_IMAGE_BYTES = 8 * 1024 * 1024; +const MAX_ARTIFACT_BYTES = 1_100_000_000; +const SUPPORTED_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".webp"]); + +function fail(message) { + throw new Error(`Catalog Seed artifact rejected: ${message}`); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function comparisonPath(value) { + const resolved = path.resolve(value); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +async function rejectSymbolicLinks(root, candidate) { + const rootResolved = path.resolve(root); + const candidateResolved = path.resolve(candidate); + const relative = path.relative(rootResolved, candidateResolved); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + fail(`path escapes source root: ${candidate}`); + } + + let current = rootResolved; + const segments = relative ? relative.split(path.sep) : []; + for (const segment of ["", ...segments]) { + if (segment) current = path.join(current, segment); + const info = await lstat(current); + if (info.isSymbolicLink()) + fail(`symbolic link or junction is not allowed: ${current}`); + } +} + +function detectImage(bytes) { + if ( + bytes.length >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff + ) { + return ".jpg"; + } + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes.subarray(1, 4).toString("ascii") === "PNG" && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return ".png"; + } + if ( + bytes.length >= 12 && + bytes.subarray(0, 4).toString("ascii") === "RIFF" && + bytes.subarray(8, 12).toString("ascii") === "WEBP" + ) { + return ".webp"; + } + return null; +} + +function normalizeRelativeImage(value, sku) { + if (typeof value !== "string" || !value.trim()) + fail(`${sku} has an empty image path`); + const normalized = value.replaceAll("\\", "/"); + if (path.posix.isAbsolute(normalized) || normalized.includes(":")) { + fail(`${sku} has a rooted image path: ${value}`); + } + const segments = normalized.split("/"); + if ( + segments.some((segment) => !segment || segment === "." || segment === "..") + ) { + fail(`${sku} has a non-canonical image path: ${value}`); + } + const extension = path.posix.extname(normalized).toLowerCase(); + if (!SUPPORTED_EXTENSIONS.has(extension)) + fail(`${sku} uses unsupported image type: ${value}`); + return normalized; +} + +async function main() { + const [sourceArgument, outputArgument] = process.argv.slice(2); + if (!sourceArgument || !outputArgument) { + fail( + "usage: node create-runtime-artifact.mjs ", + ); + } + + const sourceRoot = path.resolve(sourceArgument); + const outputRoot = path.resolve(outputArgument); + const sourceComparison = comparisonPath(sourceRoot); + const outputComparison = comparisonPath(outputRoot); + const comparisonSeparator = + process.platform === "win32" ? path.sep.toLowerCase() : path.sep; + if ( + sourceComparison === outputComparison || + outputComparison.startsWith(`${sourceComparison}${comparisonSeparator}`) + ) { + fail("output directory must be outside the source seed directory"); + } + + try { + await stat(outputRoot); + fail(`output already exists: ${outputRoot}`); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + + const catalogPath = path.join(sourceRoot, "mi-catalog.json"); + await rejectSymbolicLinks(sourceRoot, catalogPath); + const catalogBytes = await readFile(catalogPath); + const catalog = JSON.parse(catalogBytes.toString("utf8")); + if (!Array.isArray(catalog.products) || catalog.products.length === 0) { + fail("mi-catalog.json contains no products"); + } + + const skuSet = new Set(); + const relativeFiles = []; + const fileSet = new Set(); + let totalImageBytes = 0; + for (const product of catalog.products) { + const sku = typeof product.sku === "string" ? product.sku.trim() : ""; + if (!/^[A-Z0-9-]{1,64}$/.test(sku) || skuSet.has(sku)) + fail(`invalid or duplicate SKU: ${sku}`); + skuSet.add(sku); + if (!Array.isArray(product.imageFiles) || product.imageFiles.length === 0) { + fail(`${sku} has no imageFiles`); + } + if (product.imageFiles.length > MAX_IMAGES_PER_PRODUCT) { + fail(`${sku} exceeds ${MAX_IMAGES_PER_PRODUCT} images`); + } + + for (const declared of product.imageFiles) { + const imageRelative = normalizeRelativeImage(declared, sku); + const artifactRelative = path.posix.join("media", sku, imageRelative); + const artifactKey = + process.platform === "win32" + ? artifactRelative.toLowerCase() + : artifactRelative; + if (fileSet.has(artifactKey)) + fail(`duplicate image reference: ${artifactRelative}`); + fileSet.add(artifactKey); + const sourcePath = path.join(sourceRoot, ...artifactRelative.split("/")); + await rejectSymbolicLinks(sourceRoot, sourcePath); + const bytes = await readFile(sourcePath); + if (bytes.length === 0 || bytes.length > MAX_IMAGE_BYTES) { + fail( + `${artifactRelative} size ${bytes.length} is outside 1..${MAX_IMAGE_BYTES}`, + ); + } + const magicExtension = detectImage(bytes); + const declaredExtension = path.posix + .extname(artifactRelative) + .toLowerCase(); + const normalizedDeclared = + declaredExtension === ".jpeg" ? ".jpg" : declaredExtension; + if (!magicExtension || magicExtension !== normalizedDeclared) { + fail(`${artifactRelative} extension does not match its file signature`); + } + totalImageBytes += bytes.length; + if (totalImageBytes > MAX_ARTIFACT_BYTES) + fail(`images exceed ${MAX_ARTIFACT_BYTES} bytes`); + relativeFiles.push({ relative: artifactRelative, sourcePath, bytes }); + } + } + + const seedOutput = path.join(outputRoot, "scripts", "seed"); + await mkdir(seedOutput, { recursive: true }); + await cp(catalogPath, path.join(seedOutput, "mi-catalog.json")); + for (const file of relativeFiles) { + const destination = path.join(seedOutput, ...file.relative.split("/")); + await mkdir(path.dirname(destination), { recursive: true }); + await cp(file.sourcePath, destination); + } + + const checksums = [ + `${sha256(catalogBytes)} mi-catalog.json`, + ...relativeFiles.map((file) => `${sha256(file.bytes)} ${file.relative}`), + ].sort((left, right) => left.localeCompare(right, "en")); + await writeFile( + path.join(seedOutput, "SHA256SUMS"), + `${checksums.join("\n")}\n`, + "utf8", + ); + const metadata = { + formatVersion: 1, + productCount: catalog.products.length, + imageCount: relativeFiles.length, + imageBytes: totalImageBytes, + catalogSha256: sha256(catalogBytes), + checksumManifestSha256: sha256( + Buffer.from(`${checksums.join("\n")}\n`, "utf8"), + ), + }; + await writeFile( + path.join(seedOutput, "ARTIFACT.json"), + `${JSON.stringify(metadata, null, 2)}\n`, + "utf8", + ); + process.stdout.write(`${JSON.stringify(metadata)}\n`); +} + +await main(); diff --git a/scripts/seed/mi-catalog.json b/scripts/seed/mi-catalog.json new file mode 100644 index 0000000..690804d --- /dev/null +++ b/scripts/seed/mi-catalog.json @@ -0,0 +1,9963 @@ +{ + "generatedAt": "2026-08-10T13:30:36.920Z", + "source": "api2.order.mi.com + mishop-pc overview pages", + "count": 39, + "notes": "galleryFiles=购买页轮播; overviewFiles=概述页详情长图(product-images/new-product-images); imageFiles=二者拼接供 Seed", + "products": [ + { + "productId": 22137, + "sku": "SKU-AUDIO-001", + "slug": "redmi-buds-8-pro", + "name": "REDMI Buds 8 Pro", + "unitPrice": 369, + "description": "55dB深度降噪 | 新增智能查找 | 同档罕见同轴三单元", + "brand": "REDMI", + "salesCount": 34137, + "parameterList": [ + { + "name": "发布日期", + "value": "2026年1月", + "highlight": false + }, + { + "name": "特色简介", + "value": "双DAC三单元声学系统", + "highlight": false + }, + { + "name": "佩戴方式", + "value": "入耳式", + "highlight": false + }, + { + "name": "音频协议", + "value": "SBC/AAC/LHDC/MIHC/LC3", + "highlight": true + }, + { + "name": "发音单元", + "value": "11mm低音动圈单元+同轴双单元压电陶瓷片", + "highlight": true + }, + { + "name": "降噪", + "value": "55dB | 5kHz 超宽频深度降噪", + "highlight": true + }, + { + "name": "整体续航", + "value": "约35h", + "highlight": true + }, + { + "name": "通话降噪", + "value": "三麦AI通话降噪", + "highlight": false + }, + { + "name": "抗风噪", + "value": "12m/s抗风噪", + "highlight": false + }, + { + "name": "调音", + "value": "支持", + "highlight": false + }, + { + "name": "充电时间", + "value": "约120min", + "highlight": false + }, + { + "name": "耳机单次续航", + "value": "约8.5h", + "highlight": false + }, + { + "name": "快充", + "value": "充电5 分钟,续航2小时", + "highlight": false + }, + { + "name": "充电盒无线充电", + "value": "不支持", + "highlight": false + }, + { + "name": "空间音频", + "value": "支持", + "highlight": true + }, + { + "name": "佩戴检测", + "value": "支持", + "highlight": false + }, + { + "name": "双设备链接", + "value": "支持", + "highlight": true + }, + { + "name": "交互方式", + "value": "触控", + "highlight": false + }, + { + "name": "防尘防水", + "value": "IP54", + "highlight": false + }, + { + "name": "单耳重量", + "value": "5.3g(±0.1g)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "产品净重(含充电盒)", + "value": "约47g", + "highlight": false + }, + { + "name": "产品形态", + "value": "入耳式耳机", + "highlight": false, + "source": "specs-page" + }, + { + "name": "产品颜色", + "value": "玄悟黑、冰釉白、薄雾蓝", + "highlight": false, + "source": "specs-page" + }, + { + "name": "频率响应范围", + "value": "20-40kHz", + "highlight": false, + "source": "specs-page" + }, + { + "name": "单耳尺寸", + "value": "30.6*21.29*24.48mm(中号耳帽)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "整机重量", + "value": "47g(±1g)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "蓝牙版电池容量", + "value": "耳机:54mAh ;充电盒:480mAh", + "highlight": false, + "source": "specs-page" + }, + { + "name": "充电接口", + "value": "Type-C", + "highlight": false, + "source": "specs-page" + }, + { + "name": "耳机输入参数", + "value": "5V 250mA MAX(单耳机)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "充电盒输入参数", + "value": "5V 700mA MAX", + "highlight": false, + "source": "specs-page" + }, + { + "name": "充电盒输出参数", + "value": "5V 250mA MAX(单耳机)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "降噪关短续航", + "value": "8.5h(耳机单次使用)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "降噪关长续航", + "value": "35h(耳机+充电盒)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "降噪开短续航", + "value": "4.5h(耳机单次使用)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "降噪开长续航", + "value": "16h(耳机+充电盒)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "蓝牙版连接", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "蓝牙版本", + "value": "蓝牙 5.4", + "highlight": false, + "source": "specs-page" + }, + { + "name": "蓝牙编解码", + "value": "SBC / AAC / LHDC / MIHC / LC3", + "highlight": false, + "source": "specs-page" + }, + { + "name": "工作距离", + "value": "10m(无障碍空旷环境)", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "产品形态", + "value": "入耳式耳机" + }, + { + "name": "产品颜色", + "value": "玄悟黑、冰釉白、薄雾蓝" + }, + { + "name": "频率响应范围", + "value": "20-40kHz" + } + ] + }, + { + "items": [ + { + "name": "单耳重量", + "value": "5.3g(±0.1g)" + }, + { + "name": "单耳尺寸", + "value": "30.6*21.29*24.48mm(中号耳帽)" + }, + { + "name": "整机重量", + "value": "47g(±1g)" + } + ] + }, + { + "items": [ + { + "name": "蓝牙版电池容量", + "value": "耳机:54mAh ;充电盒:480mAh" + }, + { + "name": "充电接口", + "value": "Type-C" + }, + { + "name": "耳机输入参数", + "value": "5V 250mA MAX(单耳机)" + }, + { + "name": "充电盒输入参数", + "value": "5V 700mA MAX" + }, + { + "name": "充电盒输出参数", + "value": "5V 250mA MAX(单耳机)" + } + ] + }, + { + "items": [ + { + "name": "降噪关短续航", + "value": "8.5h(耳机单次使用)" + }, + { + "name": "降噪关长续航", + "value": "35h(耳机+充电盒)" + }, + { + "name": "降噪开短续航", + "value": "4.5h(耳机单次使用)" + }, + { + "name": "降噪开长续航", + "value": "16h(耳机+充电盒)" + } + ] + }, + { + "items": [ + { + "name": "蓝牙版连接", + "value": "支持" + }, + { + "name": "蓝牙版本", + "value": "蓝牙 5.4" + }, + { + "name": "蓝牙编解码", + "value": "SBC / AAC / LHDC / MIHC / LC3" + }, + { + "name": "工作距离", + "value": "10m(无障碍空旷环境)" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-buds-8-pro/specs", + "buyPageParameterCount": 21, + "specsPageParameterCount": 19, + "specifications": { + "发布日期": "2026年1月", + "特色简介": "双DAC三单元声学系统", + "佩戴方式": "入耳式", + "音频协议": "SBC/AAC/LHDC/MIHC/LC3", + "发音单元": "11mm低音动圈单元+同轴双单元压电陶瓷片", + "降噪": "55dB | 5kHz 超宽频深度降噪", + "整体续航": "约35h", + "通话降噪": "三麦AI通话降噪", + "抗风噪": "12m/s抗风噪", + "调音": "支持", + "充电时间": "约120min", + "耳机单次续航": "约8.5h", + "快充": "充电5 分钟,续航2小时", + "充电盒无线充电": "不支持", + "空间音频": "支持", + "佩戴检测": "支持", + "双设备链接": "支持", + "交互方式": "触控", + "防尘防水": "IP54", + "单耳重量": "5.3g(±0.1g)", + "产品净重(含充电盒)": "约47g", + "产品形态": "入耳式耳机", + "产品颜色": "玄悟黑、冰釉白、薄雾蓝", + "频率响应范围": "20-40kHz", + "单耳尺寸": "30.6*21.29*24.48mm(中号耳帽)", + "整机重量": "47g(±1g)", + "蓝牙版电池容量": "耳机:54mAh ;充电盒:480mAh", + "充电接口": "Type-C", + "耳机输入参数": "5V 250mA MAX(单耳机)", + "充电盒输入参数": "5V 700mA MAX", + "充电盒输出参数": "5V 250mA MAX(单耳机)", + "降噪关短续航": "8.5h(耳机单次使用)", + "降噪关长续航": "35h(耳机+充电盒)", + "降噪开短续航": "4.5h(耳机单次使用)", + "降噪开长续航": "16h(耳机+充电盒)", + "蓝牙版连接": "支持", + "蓝牙版本": "蓝牙 5.4", + "蓝牙编解码": "SBC / AAC / LHDC / MIHC / LC3", + "工作距离": "10m(无障碍空旷环境)", + "颜色": "冰釉白 / 玄悟黑 / 薄雾蓝", + "通用": "通用", + "卖点": "55dB深度降噪 | 新增智能查找 | 同档罕见同轴三单元", + "概述页": "https://www.mi.com/prod/redmi-buds-8-pro", + "参数页": "https://www.mi.com/prod/redmi-buds-8-pro/specs", + "来源": "小米商城 product_id=22137" + }, + "galleryFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png" + ], + "imageCount": 51, + "galleryCount": 9, + "overviewCount": 42, + "overviewImgPath": null + }, + { + "productId": 20978, + "sku": "SKU-AUDIO-002", + "slug": "xiaomi-buds-5-pro", + "name": "Xiaomi Buds 5 Pro", + "unitPrice": 899, + "description": "小米首发双功放三单元|55dB 深度降噪|哈曼联合调音", + "brand": "Xiaomi", + "salesCount": 32978, + "parameterList": [ + { + "name": "发布日期", + "value": "2025年2月", + "highlight": false + }, + { + "name": "特色简介", + "value": "无损原音,声声沉浸", + "highlight": false + }, + { + "name": "佩戴方式", + "value": "入耳式", + "highlight": false + }, + { + "name": "音频协议", + "value": "AAC/SBC/aptX Adaptive/aptX Lossless", + "highlight": true + }, + { + "name": "发音单元", + "value": "11mm双磁动圈+陶瓷单元+平面振膜单元", + "highlight": true + }, + { + "name": "降噪", + "value": "55dB/5kHz", + "highlight": true + }, + { + "name": "整体续航", + "value": "8h/40h;Wi-Fi版:10h/40h", + "highlight": true + }, + { + "name": "通话降噪", + "value": "100dB环境高清通话", + "highlight": false + }, + { + "name": "抗风噪", + "value": "15m/s", + "highlight": false + }, + { + "name": "调音", + "value": "哈曼金耳朵团队联合调音", + "highlight": false + }, + { + "name": "充电时间", + "value": "约45min", + "highlight": false + }, + { + "name": "耳机单次续航", + "value": "8h;Wi-Fi版:10h", + "highlight": false + }, + { + "name": "快充", + "value": "充电10min续航4h", + "highlight": false + }, + { + "name": "充电盒无线充电", + "value": "支持", + "highlight": false + }, + { + "name": "空间音频", + "value": "全维独立空间音频", + "highlight": true + }, + { + "name": "佩戴检测", + "value": "支持", + "highlight": false + }, + { + "name": "双设备链接", + "value": "支持", + "highlight": true + }, + { + "name": "交互方式", + "value": "压感+滑动", + "highlight": false + }, + { + "name": "防尘防水", + "value": "耳机IP54", + "highlight": false + }, + { + "name": "单耳重量", + "value": "约5.6g", + "highlight": false + }, + { + "name": "产品净重(含充电盒)", + "value": "约53g", + "highlight": false + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-buds-5-pro/specs", + "buyPageParameterCount": 21, + "specsPageParameterCount": 0, + "specifications": { + "发布日期": "2025年2月", + "特色简介": "无损原音,声声沉浸", + "佩戴方式": "入耳式", + "音频协议": "AAC/SBC/aptX Adaptive/aptX Lossless", + "发音单元": "11mm双磁动圈+陶瓷单元+平面振膜单元", + "降噪": "55dB/5kHz", + "整体续航": "8h/40h;Wi-Fi版:10h/40h", + "通话降噪": "100dB环境高清通话", + "抗风噪": "15m/s", + "调音": "哈曼金耳朵团队联合调音", + "充电时间": "约45min", + "耳机单次续航": "8h;Wi-Fi版:10h", + "快充": "充电10min续航4h", + "充电盒无线充电": "支持", + "空间音频": "全维独立空间音频", + "佩戴检测": "支持", + "双设备链接": "支持", + "交互方式": "压感+滑动", + "防尘防水": "耳机IP54", + "单耳重量": "约5.6g", + "产品净重(含充电盒)": "约53g", + "颜色": "钛光金 / 雪山白 / 幻影黑(Wi-Fi版)", + "通用": "通用", + "卖点": "小米首发双功放三单元|55dB 深度降噪|哈曼联合调音", + "概述页": "https://www.mi.com/prod/xiaomi-buds-5-pro", + "来源": "小米商城 product_id=20978" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 24645, + "sku": "SKU-AUDIO-003", + "slug": "redmi-headphone", + "name": "REDMI 头戴降噪耳机", + "unitPrice": 349, + "description": "40mm 超大镀钛动圈 | 深度主动降噪 | 支持小米澎湃智联", + "brand": "REDMI", + "salesCount": 36645, + "parameterList": [ + { + "name": "发布日期", + "value": "2026年6月", + "highlight": false + }, + { + "name": "特色简介", + "value": "出街即焦点 随处听现场", + "highlight": false + }, + { + "name": "佩戴方式", + "value": "头戴式", + "highlight": false + }, + { + "name": "音频协议", + "value": "AAC / SBC", + "highlight": true + }, + { + "name": "发音单元", + "value": "40mm 超大镀钛动圈单元", + "highlight": true + }, + { + "name": "降噪", + "value": "至高 42dB 主动降噪", + "highlight": true + }, + { + "name": "整体续航", + "value": "约72h", + "highlight": true + }, + { + "name": "通话降噪", + "value": "三麦 AI 通话降噪", + "highlight": false + }, + { + "name": "抗风噪", + "value": "5m/s 通话抗风噪", + "highlight": false + }, + { + "name": "调音", + "value": "支持", + "highlight": false + }, + { + "name": "充电时间", + "value": "约120min", + "highlight": false + }, + { + "name": "耳机单次续航", + "value": "约72h", + "highlight": false + }, + { + "name": "快充", + "value": "充电10分钟,听歌 5小时", + "highlight": false + }, + { + "name": "充电盒无线充电", + "value": "无充电盒", + "highlight": false + }, + { + "name": "空间音频", + "value": "支持(手机端)", + "highlight": true + }, + { + "name": "佩戴检测", + "value": "不支持", + "highlight": false + }, + { + "name": "双设备链接", + "value": "支持", + "highlight": true + }, + { + "name": "交互方式", + "value": "按键", + "highlight": false + }, + { + "name": "防尘防水", + "value": "不支持", + "highlight": false + }, + { + "name": "单耳重量", + "value": "约263g", + "highlight": false + }, + { + "name": "产品净重(含充电盒)", + "value": "约263(无充电盒)g", + "highlight": false + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-headphone/specs", + "buyPageParameterCount": 21, + "specsPageParameterCount": 0, + "specifications": { + "发布日期": "2026年6月", + "特色简介": "出街即焦点 随处听现场", + "佩戴方式": "头戴式", + "音频协议": "AAC / SBC", + "发音单元": "40mm 超大镀钛动圈单元", + "降噪": "至高 42dB 主动降噪", + "整体续航": "约72h", + "通话降噪": "三麦 AI 通话降噪", + "抗风噪": "5m/s 通话抗风噪", + "调音": "支持", + "充电时间": "约120min", + "耳机单次续航": "约72h", + "快充": "充电10分钟,听歌 5小时", + "充电盒无线充电": "无充电盒", + "空间音频": "支持(手机端)", + "佩戴检测": "不支持", + "双设备链接": "支持", + "交互方式": "按键", + "防尘防水": "不支持", + "单耳重量": "约263g", + "产品净重(含充电盒)": "约263(无充电盒)g", + "颜色": "爵士蓝 / 摇滚黑 / 交响白", + "通用": "通用", + "卖点": "40mm 超大镀钛动圈 | 深度主动降噪 | 支持小米澎湃智联", + "概述页": "https://www.mi.com/prod/redmi-headphone", + "来源": "小米商城 product_id=24645" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 16800, + "sku": "SKU-AUDIO-005", + "slug": "xiaomi-sound-pro", + "name": "Xiaomi Sound Pro", + "unitPrice": 849, + "description": "7单元旗舰声学|40W震撼低音|Harman Audio EFX|创新计算音频|高保真音频认证|全屋音响系统|NFC音乐无缝接力|组合立体声|小米妙播音频互联|AirPlay无线投送|智能感知灯效|小爱同学生活助理", + "brand": "Xiaomi", + "salesCount": 28800, + "parameterList": [ + { + "name": "红外控制", + "value": "不支持", + "highlight": true + }, + { + "name": "扬声器", + "value": "4英寸低频+3个1.5英寸全频+3个无源辐射器", + "highlight": true + }, + { + "name": "麦克风", + "value": "环形麦克风阵列", + "highlight": true + }, + { + "name": "出音方式", + "value": "360°全向出音", + "highlight": true + }, + { + "name": "组合立体声", + "value": "支持", + "highlight": true + }, + { + "name": "语音通话", + "value": "支持", + "highlight": true + }, + { + "name": "Wi-Fi 频段", + "value": "2.4G/5GHzHz", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-sound-pro/specs", + "buyPageParameterCount": 7, + "specsPageParameterCount": 0, + "specifications": { + "红外控制": "不支持", + "扬声器": "4英寸低频+3个1.5英寸全频+3个无源辐射器", + "麦克风": "环形麦克风阵列", + "出音方式": "360°全向出音", + "组合立体声": "支持", + "语音通话": "支持", + "Wi-Fi 频段": "2.4G/5GHzHz", + "颜色": "黑色", + "通用": "通用", + "卖点": "7单元旗舰声学|40W震撼低音|Harman Audio EFX|创新计算音频|高保真音频认证|全屋音响系统|NFC音乐无缝接力|组合立体声|小米妙播音频互联|AirPlay无线投送|智能感知灯效|小爱同学生活助理", + "概述页": "https://www.mi.com/prod/xiaomi-sound-pro", + "来源": "小米商城 product_id=16800" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 12541, + "sku": "SKU-DIG-003", + "slug": "xiaomi-car-charger-100w", + "name": "小米车载充电器快充版1A1C 100W", + "unitPrice": 99, + "description": "USB-C 100W MAX 快充/USB-A,USB-C双口输出/环形炫彩灯效显示", + "brand": "Xiaomi", + "salesCount": 24541, + "parameterList": [], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-car-charger-100w/specs", + "buyPageParameterCount": 0, + "specsPageParameterCount": 0, + "specifications": { + "颜色": "黑色", + "通用": "通用", + "卖点": "USB-C 100W MAX 快充/USB-A,USB-C双口输出/环形炫彩灯效显示", + "概述页": "https://www.mi.com/prod/xiaomi-car-charger-100w", + "来源": "小米商城 product_id=12541" + }, + "galleryFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-gallery.jpg", + "gallery/02-gallery.jpg", + "gallery/03-gallery.jpg", + "gallery/04-gallery.jpg", + "gallery/05-cover.png", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-gallery.jpg", + "gallery/02-gallery.jpg", + "gallery/03-gallery.jpg", + "gallery/04-gallery.jpg", + "gallery/05-cover.png", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 13143, + "sku": "SKU-HOM-AC1", + "slug": "xiaomi-air-conditioner", + "name": "巨省电 1.5匹新一级能效 小米空调", + "unitPrice": 1687.9, + "description": "限时特惠!以旧换新至高补贴700元 强劲制冷 | 高效节能 | 智能互联", + "brand": "Xiaomi", + "salesCount": 25143, + "parameterList": [ + { + "name": "款式", + "value": "壁挂式", + "highlight": true + }, + { + "name": "产品系列", + "value": "巨省电", + "highlight": true + }, + { + "name": "匹数", + "value": "1.5匹", + "highlight": true + }, + { + "name": "能效", + "value": "一级", + "highlight": true + }, + { + "name": "适用面积", + "value": "16-20㎡", + "highlight": true + }, + { + "name": "冷暖类型", + "value": "冷暖", + "highlight": true + }, + { + "name": "定频/变频", + "value": "变频", + "highlight": true + }, + { + "name": "是否支持小爱同学控制", + "value": "是", + "highlight": true + }, + { + "name": "整机质保期", + "value": "10年", + "highlight": true + }, + { + "name": "APF值", + "value": "5.27", + "highlight": true + }, + { + "name": "制冷量(整机)", + "value": "3500(150-5100)", + "highlight": true + }, + { + "name": "制热量(整机)", + "value": "5000(150-6610)W", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-air-conditioner/specs", + "buyPageParameterCount": 12, + "specsPageParameterCount": 0, + "specifications": { + "款式": "壁挂式", + "产品系列": "巨省电", + "匹数": "1.5匹", + "能效": "一级", + "适用面积": "16-20㎡", + "冷暖类型": "冷暖", + "定频/变频": "变频", + "是否支持小爱同学控制": "是", + "整机质保期": "10年", + "APF值": "5.27", + "制冷量(整机)": "3500(150-5100)", + "制热量(整机)": "5000(150-6610)W", + "卖点": "限时特惠!以旧换新至高补贴700元 强劲制冷 | 高效节能 | 智能互联", + "概述页": "https://www.mi.com/prod/xiaomi-air-conditioner", + "来源": "小米商城 product_id=13143" + }, + "galleryFiles": [ + "gallery/00-cover.png", + "gallery/01-detail.jpg", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-cover.png", + "gallery/01-detail.jpg", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 22504, + "sku": "SKU-HOM-RF1", + "slug": "mijia-fridge-636", + "name": "米家冰箱 636L对开", + "unitPrice": 1999, + "description": "636L超大容量丨全域抗菌净味丨10kg/12h超强冷冻力", + "brand": "米家", + "salesCount": 34504, + "parameterList": [ + { + "name": "能效等级", + "value": "1级", + "highlight": true + }, + { + "name": "尺寸", + "value": "910*715*1770mm", + "highlight": true + }, + { + "name": "制冷方式", + "value": "风冷无霜", + "highlight": true + }, + { + "name": "面板材质", + "value": "金属面板", + "highlight": true + }, + { + "name": "制冷系统", + "value": "单系统", + "highlight": true + }, + { + "name": "压缩机类型", + "value": "变频", + "highlight": true + }, + { + "name": "噪音值", + "value": "34dB", + "highlight": true + }, + { + "name": "放置方式", + "value": "独立式", + "highlight": false + }, + { + "name": "净化技术", + "value": "银离子抗菌", + "highlight": true + }, + { + "name": "智能互联", + "value": "支持", + "highlight": true + }, + { + "name": "总容量", + "value": "636L", + "highlight": false + }, + { + "name": "冷藏室容积", + "value": "394L", + "highlight": true + }, + { + "name": "冷冻室容积", + "value": "242L", + "highlight": true + }, + { + "name": "变温室容积", + "value": "-L", + "highlight": true + }, + { + "name": "保鲜技术", + "value": "无", + "highlight": false + }, + { + "name": "耗电量", + "value": "0.95kW·h/24h", + "highlight": true + }, + { + "name": "深度", + "value": ">70CM", + "highlight": false + }, + { + "name": "宽度", + "value": ">90CM", + "highlight": false + }, + { + "name": "价格带", + "value": "2000-2500元", + "highlight": false + }, + { + "name": "门体类型", + "value": "对开门", + "highlight": false + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/mijia-fridge-636/specs", + "buyPageParameterCount": 20, + "specsPageParameterCount": 0, + "specifications": { + "能效等级": "1级", + "尺寸": "910*715*1770mm", + "制冷方式": "风冷无霜", + "面板材质": "金属面板", + "制冷系统": "单系统", + "压缩机类型": "变频", + "噪音值": "34dB", + "放置方式": "独立式", + "净化技术": "银离子抗菌", + "智能互联": "支持", + "总容量": "636L", + "冷藏室容积": "394L", + "冷冻室容积": "242L", + "变温室容积": "-L", + "保鲜技术": "无", + "耗电量": "0.95kW·h/24h", + "深度": ">70CM", + "宽度": ">90CM", + "价格带": "2000-2500元", + "门体类型": "对开门", + "颜色": "墨羽岩", + "通用": "通用", + "卖点": "636L超大容量丨全域抗菌净味丨10kg/12h超强冷冻力", + "概述页": "https://www.mi.com/prod/mijia-fridge-636", + "来源": "小米商城 product_id=22504" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg" + ], + "imageCount": 8, + "galleryCount": 8, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 15638, + "sku": "SKU-HOM-RF2", + "slug": "mijia-fridge-216", + "name": "米家冰箱 216L三门风冷", + "unitPrice": 1199, + "description": "风冷无霜不结冰|离子抗菌净味|纤巧身材不占地", + "brand": "米家", + "salesCount": 27638, + "parameterList": [ + { + "name": "能效等级", + "value": "2级", + "highlight": true + }, + { + "name": "尺寸", + "value": "525*648*1728mm", + "highlight": true + }, + { + "name": "制冷方式", + "value": "风冷", + "highlight": true + }, + { + "name": "门体类型", + "value": "三门", + "highlight": false + }, + { + "name": "面板材质", + "value": "金属", + "highlight": true + }, + { + "name": "耗电量", + "value": "0.63kW·h/24h", + "highlight": true + }, + { + "name": "噪音值", + "value": "38dB", + "highlight": true + }, + { + "name": "压缩机类型", + "value": "定频", + "highlight": true + }, + { + "name": "冷藏室容积", + "value": "122L", + "highlight": true + }, + { + "name": "冷冻室容积", + "value": "62L", + "highlight": true + }, + { + "name": "变温室容积", + "value": "32L", + "highlight": true + }, + { + "name": "总容量", + "value": "216L", + "highlight": false + }, + { + "name": "智能互联", + "value": "不支持", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/mijia-fridge-216/specs", + "buyPageParameterCount": 13, + "specsPageParameterCount": 0, + "specifications": { + "能效等级": "2级", + "尺寸": "525*648*1728mm", + "制冷方式": "风冷", + "门体类型": "三门", + "面板材质": "金属", + "耗电量": "0.63kW·h/24h", + "噪音值": "38dB", + "压缩机类型": "定频", + "冷藏室容积": "122L", + "冷冻室容积": "62L", + "变温室容积": "32L", + "总容量": "216L", + "智能互联": "不支持", + "颜色": "铂银", + "通用": "通用", + "卖点": "风冷无霜不结冰|离子抗菌净味|纤巧身材不占地", + "概述页": "https://www.mi.com/prod/mijia-fridge-216", + "来源": "小米商城 product_id=15638" + }, + "galleryFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-gallery.jpg", + "gallery/02-gallery.jpg", + "gallery/03-gallery.jpg", + "gallery/04-gallery.jpg", + "gallery/05-cover.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-gallery.jpg", + "gallery/02-gallery.jpg", + "gallery/03-gallery.jpg", + "gallery/04-gallery.jpg", + "gallery/05-cover.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 21556, + "sku": "SKU-HOM-WM1", + "slug": "mijia-washer-12kg", + "name": "米家洗衣机 洗烘12kg", + "unitPrice": 1899, + "description": "12kg洗涤+9kg烘干,1.2洗净比,26种洗烘护程序,高温蒸汽除菌螨,DD直驱电机,610mm超薄嵌入,小米澎湃智联", + "brand": "米家", + "salesCount": 33556, + "parameterList": [ + { + "name": "产品类型", + "value": "洗烘一体机", + "highlight": true + }, + { + "name": "洗涤容量", + "value": "12kg", + "highlight": true + }, + { + "name": "烘干容量", + "value": "9kg", + "highlight": true + }, + { + "name": "宽*深*高", + "value": "598*610*850mm", + "highlight": true + }, + { + "name": "电机类型", + "value": "直驱变频电机", + "highlight": true + }, + { + "name": "能效等级", + "value": "一级", + "highlight": true + }, + { + "name": "洗净比", + "value": "1.2", + "highlight": true + }, + { + "name": "除菌方式", + "value": "高温蒸汽除菌", + "highlight": true + }, + { + "name": "排水方式", + "value": "上排水", + "highlight": true + }, + { + "name": "开门方式", + "value": "前开式", + "highlight": true + }, + { + "name": "最高转速", + "value": "1200r/min", + "highlight": true + }, + { + "name": "智能互联", + "value": "支持", + "highlight": true + }, + { + "name": "内筒材质", + "value": "不锈钢", + "highlight": true + }, + { + "name": "烘干方式", + "value": "冷凝烘干", + "highlight": true + }, + { + "name": "烘干功率", + "value": "1500W", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/mijia-washer-12kg/specs", + "buyPageParameterCount": 15, + "specsPageParameterCount": 0, + "specifications": { + "产品类型": "洗烘一体机", + "洗涤容量": "12kg", + "烘干容量": "9kg", + "宽*深*高": "598*610*850mm", + "电机类型": "直驱变频电机", + "能效等级": "一级", + "洗净比": "1.2", + "除菌方式": "高温蒸汽除菌", + "排水方式": "上排水", + "开门方式": "前开式", + "最高转速": "1200r/min", + "智能互联": "支持", + "内筒材质": "不锈钢", + "烘干方式": "冷凝烘干", + "烘干功率": "1500W", + "颜色": "灰色", + "通用": "通用", + "卖点": "12kg洗涤+9kg烘干,1.2洗净比,26种洗烘护程序,高温蒸汽除菌螨,DD直驱电机,610mm超薄嵌入,小米澎湃智联", + "概述页": "https://www.mi.com/prod/mijia-washer-12kg", + "来源": "小米商城 product_id=21556" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 21811, + "sku": "SKU-HOM-WM2", + "slug": "mijia-washer-pro-10kg", + "name": "米家三区洗衣机Pro 滚筒10kg", + "unitPrice": 4699, + "description": "小米超电解技术|S级健康洗护评价|标准尺寸三区洗", + "brand": "米家", + "salesCount": 33811, + "parameterList": [ + { + "name": "产品类型", + "value": "分区洗衣机", + "highlight": true + }, + { + "name": "洗涤容量", + "value": "0.4(左上筒)+0.4(右上筒)+10(下筒)kg", + "highlight": true + }, + { + "name": "烘干容量", + "value": "/kg", + "highlight": true + }, + { + "name": "宽*深*高", + "value": "598*607*850mm", + "highlight": true + }, + { + "name": "电机类型", + "value": "直驱变频电机", + "highlight": true + }, + { + "name": "能效等级", + "value": "一级", + "highlight": true + }, + { + "name": "洗净比", + "value": "大筒1.3 小筒1.75", + "highlight": true + }, + { + "name": "除菌方式", + "value": "超电解全水路专业杀菌", + "highlight": true + }, + { + "name": "洗涤噪音值", + "value": "46dB", + "highlight": true + }, + { + "name": "排水方式", + "value": "上排水", + "highlight": true + }, + { + "name": "开门方式", + "value": "前开式", + "highlight": true + }, + { + "name": "最高转速", + "value": "1200(左上筒)+1200(右上筒) +1200(下筒)r/min", + "highlight": true + }, + { + "name": "智能互联", + "value": "支持", + "highlight": true + }, + { + "name": "内筒材质", + "value": "不锈钢", + "highlight": true + }, + { + "name": "烘干方式", + "value": "无", + "highlight": true + }, + { + "name": "洗涤功率", + "value": "/\t\tW", + "highlight": true + }, + { + "name": "烘干功率", + "value": "/\t\tW", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/mijia-washer-pro-10kg/specs", + "buyPageParameterCount": 17, + "specsPageParameterCount": 0, + "specifications": { + "产品类型": "分区洗衣机", + "洗涤容量": "0.4(左上筒)+0.4(右上筒)+10(下筒)kg", + "烘干容量": "/kg", + "宽*深*高": "598*607*850mm", + "电机类型": "直驱变频电机", + "能效等级": "一级", + "洗净比": "大筒1.3 小筒1.75", + "除菌方式": "超电解全水路专业杀菌", + "洗涤噪音值": "46dB", + "排水方式": "上排水", + "开门方式": "前开式", + "最高转速": "1200(左上筒)+1200(右上筒) +1200(下筒)r/min", + "智能互联": "支持", + "内筒材质": "不锈钢", + "烘干方式": "无", + "洗涤功率": "/\t\tW", + "烘干功率": "/\t\tW", + "颜色": "星缎银", + "通用": "通用", + "卖点": "小米超电解技术|S级健康洗护评价|标准尺寸三区洗", + "概述页": "https://www.mi.com/prod/mijia-washer-pro-10kg", + "来源": "小米商城 product_id=21811" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.png", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.png", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 20559, + "sku": "SKU-NET-003", + "slug": "xiaomi-be3600-pro", + "name": "Xiaomi全屋路由 BE3600Pro 套装", + "unitPrice": 699, + "description": "全屋覆盖,智能连接,内置小米中枢网关,实力双路由,出厂预配对,高通4核处理器", + "brand": "Xiaomi", + "salesCount": 32559, + "parameterList": [ + { + "name": "无线协议", + "value": "Wi-Fi 7", + "highlight": true + }, + { + "name": "无线速率", + "value": "3600M级", + "highlight": true + }, + { + "name": "信号放大器", + "value": "4路信号放大器", + "highlight": false + }, + { + "name": "内存", + "value": "512MB", + "highlight": true + }, + { + "name": "CPU", + "value": "高通四核1.1G", + "highlight": true + }, + { + "name": "适用面积", + "value": "160㎡以下", + "highlight": true + }, + { + "name": "Mesh组网", + "value": "支持", + "highlight": false + }, + { + "name": "整机接口", + "value": "WAN/LAN*4", + "highlight": true + }, + { + "name": "WAN口速率", + "value": "最高支持2500M自适应", + "highlight": true + }, + { + "name": "LAN口速率", + "value": "最高支持1000M自适应", + "highlight": true + }, + { + "name": "宽带适配", + "value": "≤2500M", + "highlight": true + }, + { + "name": "调制解调", + "value": "4K QAM", + "highlight": false + }, + { + "name": "天线", + "value": "单台4根", + "highlight": true + }, + { + "name": "带机量", + "value": "256台", + "highlight": false + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-be3600-pro/specs", + "buyPageParameterCount": 14, + "specsPageParameterCount": 0, + "specifications": { + "无线协议": "Wi-Fi 7", + "无线速率": "3600M级", + "信号放大器": "4路信号放大器", + "内存": "512MB", + "CPU": "高通四核1.1G", + "适用面积": "160㎡以下", + "Mesh组网": "支持", + "整机接口": "WAN/LAN*4", + "WAN口速率": "最高支持2500M自适应", + "LAN口速率": "最高支持1000M自适应", + "宽带适配": "≤2500M", + "调制解调": "4K QAM", + "天线": "单台4根", + "带机量": "256台", + "颜色": "灰色", + "通用": "通用", + "卖点": "全屋覆盖,智能连接,内置小米中枢网关,实力双路由,出厂预配对,高通4核处理器", + "概述页": "https://www.mi.com/prod/xiaomi-be3600-pro", + "来源": "小米商城 product_id=20559" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg" + ], + "imageCount": 8, + "galleryCount": 8, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 24035, + "sku": "SKU-NOTE-001", + "slug": "redmibook-pro-14-2026", + "name": "REDMI Book Pro 14 2026", + "unitPrice": 7199, + "description": "第三代英特尔酷睿Ultra处理器 | 至高70W整机性能释放 | 2.8K专业护眼屏", + "brand": "REDMI", + "salesCount": 36035, + "parameterList": [ + { + "name": "屏幕分辨率", + "value": "2880*1800", + "highlight": true + }, + { + "name": "CPU类型", + "value": "Ultra X7 358H/Ultra 5 338H/Ultra 5 325", + "highlight": true + }, + { + "name": "显卡类型", + "value": "集显", + "highlight": true + }, + { + "name": "屏幕尺寸", + "value": "14英寸", + "highlight": true + }, + { + "name": "固态硬盘", + "value": "1024GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "24GB/32GB", + "highlight": true + }, + { + "name": "重量(净重)", + "value": "轻至约1.48kg", + "highlight": true + }, + { + "name": "操作系统", + "value": "Windows 11 家庭中文版", + "highlight": true + }, + { + "name": "屏幕亮度", + "value": "500尼特", + "highlight": true + }, + { + "name": "屏幕色域", + "value": "100%sRGB", + "highlight": true + }, + { + "name": "屏幕刷新率", + "value": "120Hz", + "highlight": true + }, + { + "name": "电池容量", + "value": "92Wh", + "highlight": true + }, + { + "name": "电源适配器", + "value": "100W GaN", + "highlight": true + }, + { + "name": "屏幕比例", + "value": "16:10", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmibook-pro-14-2026/specs", + "buyPageParameterCount": 14, + "specsPageParameterCount": 0, + "specifications": { + "屏幕分辨率": "2880*1800", + "CPU类型": "Ultra X7 358H/Ultra 5 338H/Ultra 5 325", + "显卡类型": "集显", + "屏幕尺寸": "14英寸", + "固态硬盘": "1024GB", + "内存容量": "24GB/32GB", + "重量(净重)": "轻至约1.48kg", + "操作系统": "Windows 11 家庭中文版", + "屏幕亮度": "500尼特", + "屏幕色域": "100%sRGB", + "屏幕刷新率": "120Hz", + "电池容量": "92Wh", + "电源适配器": "100W GaN", + "屏幕比例": "16:10", + "颜色": "晴空蓝", + "处理器": "U5-325 24+1T / U5-338H 32+1T / X7-358H 32+1T", + "卖点": "第三代英特尔酷睿Ultra处理器 | 至高70W整机性能释放 | 2.8K专业护眼屏", + "概述页": "https://www.mi.com/prod/redmibook-pro-14-2026", + "来源": "小米商城 product_id=24035" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 24282, + "sku": "SKU-NOTE-002", + "slug": "redmibook-14-2026", + "name": "REDMI Book 14 2026", + "unitPrice": 5699, + "description": "英特尔酷睿Ultra,2.8K超清镜面屏,高效跨端智联", + "brand": "REDMI", + "salesCount": 36282, + "parameterList": [ + { + "name": "屏幕分辨率", + "value": "2880*1800", + "highlight": true + }, + { + "name": "CPU类型", + "value": "Ultra5-125H", + "highlight": true + }, + { + "name": "显卡类型", + "value": "集显", + "highlight": true + }, + { + "name": "屏幕尺寸", + "value": "14英寸", + "highlight": true + }, + { + "name": "固态硬盘", + "value": "512GB/1024GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "16GB/32GB", + "highlight": true + }, + { + "name": "重量(净重)", + "value": "1.49kg", + "highlight": true + }, + { + "name": "操作系统", + "value": "Windows 11 家庭中文版", + "highlight": true + }, + { + "name": "屏幕亮度", + "value": "500尼特", + "highlight": true + }, + { + "name": "屏幕色域", + "value": "100%sRGB", + "highlight": true + }, + { + "name": "屏幕刷新率", + "value": "120Hz", + "highlight": true + }, + { + "name": "电池容量", + "value": "80Wh", + "highlight": true + }, + { + "name": "电源适配器", + "value": "100W GaN", + "highlight": true + }, + { + "name": "屏幕比例", + "value": "16:10", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmibook-14-2026/specs", + "buyPageParameterCount": 14, + "specsPageParameterCount": 0, + "specifications": { + "屏幕分辨率": "2880*1800", + "CPU类型": "Ultra5-125H", + "显卡类型": "集显", + "屏幕尺寸": "14英寸", + "固态硬盘": "512GB/1024GB", + "内存容量": "16GB/32GB", + "重量(净重)": "1.49kg", + "操作系统": "Windows 11 家庭中文版", + "屏幕亮度": "500尼特", + "屏幕色域": "100%sRGB", + "屏幕刷新率": "120Hz", + "电池容量": "80Wh", + "电源适配器": "100W GaN", + "屏幕比例": "16:10", + "颜色": "星光银", + "处理器": "Ultra5-125H/16G/512G / Ultra5-125H/16G/1T / Ultra5-125H/32G/1T", + "卖点": "英特尔酷睿Ultra,2.8K超清镜面屏,高效跨端智联", + "概述页": "https://www.mi.com/prod/redmibook-14-2026", + "来源": "小米商城 product_id=24282" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 24038, + "sku": "SKU-NOTE-003", + "slug": "redmibook-16-2026", + "name": "REDMI Book 16 2026", + "unitPrice": 5899, + "description": "英特尔酷睿Ultra,2.5K超清镜面屏,高效跨端智联", + "brand": "REDMI", + "salesCount": 36038, + "parameterList": [ + { + "name": "屏幕分辨率", + "value": "2560*1600", + "highlight": true + }, + { + "name": "CPU类型", + "value": "Ultra5-125H", + "highlight": true + }, + { + "name": "显卡类型", + "value": "集显", + "highlight": true + }, + { + "name": "屏幕尺寸", + "value": "16英寸", + "highlight": true + }, + { + "name": "固态硬盘", + "value": "512GB/1024GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "16GB/32GB", + "highlight": true + }, + { + "name": "重量(净重)", + "value": "1.86kg", + "highlight": true + }, + { + "name": "操作系统", + "value": "Windows 11 家庭中文版", + "highlight": true + }, + { + "name": "屏幕亮度", + "value": "400尼特", + "highlight": true + }, + { + "name": "屏幕色域", + "value": "100%sRGB", + "highlight": true + }, + { + "name": "屏幕刷新率", + "value": "120Hz", + "highlight": true + }, + { + "name": "电池容量", + "value": "80Wh", + "highlight": true + }, + { + "name": "电源适配器", + "value": "100W GaN", + "highlight": true + }, + { + "name": "屏幕比例", + "value": "16:10", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmibook-16-2026/specs", + "buyPageParameterCount": 14, + "specsPageParameterCount": 0, + "specifications": { + "屏幕分辨率": "2560*1600", + "CPU类型": "Ultra5-125H", + "显卡类型": "集显", + "屏幕尺寸": "16英寸", + "固态硬盘": "512GB/1024GB", + "内存容量": "16GB/32GB", + "重量(净重)": "1.86kg", + "操作系统": "Windows 11 家庭中文版", + "屏幕亮度": "400尼特", + "屏幕色域": "100%sRGB", + "屏幕刷新率": "120Hz", + "电池容量": "80Wh", + "电源适配器": "100W GaN", + "屏幕比例": "16:10", + "颜色": "星辰灰", + "处理器": "Ultra5-125H/16G/512G / Ultra5-125H/16G/1T / Ultra5-125H/32G/1T", + "卖点": "英特尔酷睿Ultra,2.5K超清镜面屏,高效跨端智联", + "概述页": "https://www.mi.com/prod/redmibook-16-2026", + "来源": "小米商城 product_id=24038" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 24037, + "sku": "SKU-NOTE-004", + "slug": "redmibook-pro-16-2026", + "name": "REDMI Book Pro 16 2026", + "unitPrice": 7399, + "description": "第三代英特尔酷睿Ultra处理器 | 至高80W整机性能释放 | 3.2K专业护眼屏", + "brand": "REDMI", + "salesCount": 36037, + "parameterList": [], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmibook-pro-16-2026/specs", + "buyPageParameterCount": 0, + "specsPageParameterCount": 0, + "specifications": { + "颜色": "星辰灰", + "处理器": "U5-325 24+1T / U5-338H 32+1T / X7-358H 32+1T", + "卖点": "第三代英特尔酷睿Ultra处理器 | 至高80W整机性能释放 | 3.2K专业护眼屏", + "概述页": "https://www.mi.com/prod/redmibook-pro-16-2026", + "来源": "小米商城 product_id=24037" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 21871, + "sku": "SKU-PAD-001", + "slug": "xiaomi-pad-8-pro", + "name": "Xiaomi Pad 8 Pro", + "unitPrice": 2999, + "description": "【至高享15%政府补贴,直播间下单赠保护壳,买赠价值655元游戏礼包】 骁龙 8 至尊版旗舰处理器 | 11.2英寸高清护眼屏 | 薄至5.75mm 轻至485g | 9200mAh 大电池 | 小米澎湃OS 3 | PC 级WPS、CAD", + "brand": "Xiaomi", + "salesCount": 33871, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "11.2英寸", + "highlight": true + }, + { + "name": "CPU", + "value": "骁龙8至尊版", + "highlight": true + }, + { + "name": "运行内存", + "value": "8GB/12GB/16GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "128GB/256GB/512GB", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "3200*2136", + "highlight": true + }, + { + "name": "屏幕比例", + "value": "3:2", + "highlight": false + }, + { + "name": "屏幕刷新率", + "value": "至高144Hz", + "highlight": true + }, + { + "name": "屏幕类型", + "value": "LCD", + "highlight": false + }, + { + "name": "网络连接", + "value": "Wi-Fi 7", + "highlight": true + }, + { + "name": "是否支持IPv6", + "value": "支持", + "highlight": false + }, + { + "name": "电池容量", + "value": "9200mAh", + "highlight": true + }, + { + "name": "后置摄像头", + "value": "5000W", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "3200W", + "highlight": true + }, + { + "name": "操作系统", + "value": "HyperOS 3", + "highlight": false + }, + { + "name": "杜比视界", + "value": "支持", + "highlight": false + }, + { + "name": "护眼模式", + "value": "支持", + "highlight": false + }, + { + "name": "USB接口", + "value": "USB 3.2 Gen 1", + "highlight": false + }, + { + "name": "音频接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "厚度", + "value": "5.75mm/5.80mm", + "highlight": false + }, + { + "name": "有线快充", + "value": "67W", + "highlight": false + }, + { + "name": "无线快充", + "value": "不支持", + "highlight": false + }, + { + "name": "是否支持专用触控笔", + "value": "支持", + "highlight": false + }, + { + "name": "是否支持专用键盘", + "value": "支持", + "highlight": false + }, + { + "name": "扬声器数量", + "value": "4个", + "highlight": false + }, + { + "name": "V节律友好认证", + "value": "V无频闪认证", + "highlight": false, + "source": "specs-page" + }, + { + "name": "后置5000万像素高清镜头", + "value": "/1.8 PDAF", + "highlight": false, + "source": "specs-page" + }, + { + "name": "前置3200万像素超广镜头", + "value": "/2.2", + "highlight": false, + "source": "specs-page" + }, + { + "name": "键盘磁吸触点连接", + "value": "常规版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "笔充磁吸", + "value": "柔光版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)/擦屏布", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "V节律友好认证", + "value": "V无频闪认证" + } + ] + }, + { + "items": [ + { + "name": "后置5000万像素高清镜头", + "value": "/1.8 PDAF" + } + ] + }, + { + "items": [ + { + "name": "前置3200万像素超广镜头", + "value": "/2.2" + } + ] + }, + { + "items": [ + { + "name": "键盘磁吸触点连接", + "value": "常规版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)" + }, + { + "name": "笔充磁吸", + "value": "柔光版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)/擦屏布" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-pad-8-pro/specs", + "buyPageParameterCount": 24, + "specsPageParameterCount": 5, + "specifications": { + "屏幕尺寸": "11.2英寸", + "CPU": "骁龙8至尊版", + "运行内存": "8GB/12GB/16GB", + "内存容量": "128GB/256GB/512GB", + "屏幕分辨率": "3200*2136", + "屏幕比例": "3:2", + "屏幕刷新率": "至高144Hz", + "屏幕类型": "LCD", + "网络连接": "Wi-Fi 7", + "是否支持IPv6": "支持", + "电池容量": "9200mAh", + "后置摄像头": "5000W", + "前置摄像头": "3200W", + "操作系统": "HyperOS 3", + "杜比视界": "支持", + "护眼模式": "支持", + "USB接口": "USB 3.2 Gen 1", + "音频接口": "Type-C", + "厚度": "5.75mm/5.80mm", + "有线快充": "67W", + "无线快充": "不支持", + "是否支持专用触控笔": "支持", + "是否支持专用键盘": "支持", + "扬声器数量": "4个", + "V节律友好认证": "V无频闪认证", + "后置5000万像素高清镜头": "/1.8 PDAF", + "前置3200万像素超广镜头": "/2.2", + "键盘磁吸触点连接": "常规版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)", + "笔充磁吸": "柔光版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)/擦屏布", + "版本": "8GB+128GB / 8GB+256GB / 12GB+256GB / 12GB+512GB / 16GB+512GB / 12GB+256GB 柔光版 / 12GB+512GB 柔光版 / 16GB+512GB 柔光版", + "颜色": "黑色 / 冰晶蓝 / 松石绿", + "卖点": "【至高享15%政府补贴,直播间下单赠保护壳,买赠价值655元游戏礼包】 骁龙 8 至尊版旗舰处理器 | 11.2英寸高清护眼屏 | 薄至5.75mm 轻至485g | 9200mAh 大电池 | 小米澎湃OS 3 | PC 级WPS、CAD", + "概述页": "https://www.mi.com/prod/xiaomi-pad-8-pro", + "参数页": "https://www.mi.com/prod/xiaomi-pad-8-pro/specs", + "来源": "小米商城 product_id=21871" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-cover.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-cover.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png" + ], + "imageCount": 59, + "galleryCount": 9, + "overviewCount": 50, + "overviewImgPath": null + }, + { + "productId": 21872, + "sku": "SKU-PAD-002", + "slug": "xiaomi-pad-8", + "name": "Xiaomi Pad 8", + "unitPrice": 2799, + "description": "【至高享15%政府补贴,直播间下单赠保护壳,买赠价值655元游戏礼包】 第四代骁龙 8s 旗舰处理器 | 11.2英寸高清护眼屏 | 薄至5.75mm 轻至485g | 9200mAh 大电池 | 小米澎湃OS 3 | PC 级WPS、CAD", + "brand": "Xiaomi", + "salesCount": 33872, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "11.2英寸", + "highlight": true + }, + { + "name": "CPU", + "value": "第四代骁龙8s", + "highlight": true + }, + { + "name": "运行内存", + "value": "8GB/12GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "128GB/256GB", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "3200*2136", + "highlight": true + }, + { + "name": "屏幕比例", + "value": "3:2", + "highlight": false + }, + { + "name": "屏幕刷新率", + "value": "至高144Hz", + "highlight": true + }, + { + "name": "屏幕类型", + "value": "LCD", + "highlight": false + }, + { + "name": "网络连接", + "value": "Wi-Fi 7", + "highlight": true + }, + { + "name": "是否支持IPv6", + "value": "支持", + "highlight": false + }, + { + "name": "电池容量", + "value": "9200mAh", + "highlight": true + }, + { + "name": "后置摄像头", + "value": "1300W", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "800W", + "highlight": true + }, + { + "name": "操作系统", + "value": "HyperOS 3", + "highlight": false + }, + { + "name": "杜比视界", + "value": "支持", + "highlight": false + }, + { + "name": "护眼模式", + "value": "支持", + "highlight": false + }, + { + "name": "USB接口", + "value": "USB 3.2 Gen 1", + "highlight": false + }, + { + "name": "音频接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "厚度", + "value": "5.75mm/5.80mm", + "highlight": false + }, + { + "name": "有线快充", + "value": "45W", + "highlight": false + }, + { + "name": "无线快充", + "value": "不支持", + "highlight": false + }, + { + "name": "是否支持专用触控笔", + "value": "支持", + "highlight": false + }, + { + "name": "是否支持专用键盘", + "value": "支持", + "highlight": false + }, + { + "name": "扬声器数量", + "value": "4个", + "highlight": false + }, + { + "name": "V节律友好认证", + "value": "V无频闪认证", + "highlight": false, + "source": "specs-page" + }, + { + "name": "后置1300万像素高清镜头", + "value": "/2.2 PDAF", + "highlight": false, + "source": "specs-page" + }, + { + "name": "前置800万像素超广镜头", + "value": "/2.28", + "highlight": false, + "source": "specs-page" + }, + { + "name": "键盘磁吸触点连接", + "value": "常规版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "笔充磁吸", + "value": "柔光版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)/擦屏布", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "V节律友好认证", + "value": "V无频闪认证" + } + ] + }, + { + "items": [ + { + "name": "后置1300万像素高清镜头", + "value": "/2.2 PDAF" + } + ] + }, + { + "items": [ + { + "name": "前置800万像素超广镜头", + "value": "/2.28" + } + ] + }, + { + "items": [ + { + "name": "键盘磁吸触点连接", + "value": "常规版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)" + }, + { + "name": "笔充磁吸", + "value": "柔光版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)/擦屏布" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-pad-8/specs", + "buyPageParameterCount": 24, + "specsPageParameterCount": 5, + "specifications": { + "屏幕尺寸": "11.2英寸", + "CPU": "第四代骁龙8s", + "运行内存": "8GB/12GB", + "内存容量": "128GB/256GB", + "屏幕分辨率": "3200*2136", + "屏幕比例": "3:2", + "屏幕刷新率": "至高144Hz", + "屏幕类型": "LCD", + "网络连接": "Wi-Fi 7", + "是否支持IPv6": "支持", + "电池容量": "9200mAh", + "后置摄像头": "1300W", + "前置摄像头": "800W", + "操作系统": "HyperOS 3", + "杜比视界": "支持", + "护眼模式": "支持", + "USB接口": "USB 3.2 Gen 1", + "音频接口": "Type-C", + "厚度": "5.75mm/5.80mm", + "有线快充": "45W", + "无线快充": "不支持", + "是否支持专用触控笔": "支持", + "是否支持专用键盘": "支持", + "扬声器数量": "4个", + "V节律友好认证": "V无频闪认证", + "后置1300万像素高清镜头": "/2.2 PDAF", + "前置800万像素超广镜头": "/2.28", + "键盘磁吸触点连接": "常规版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)", + "笔充磁吸": "柔光版:平板主机/电源适配器/USB Type-C数据线/说明书(含三包凭证)/擦屏布", + "版本": "8GB+256GB / 12GB+256GB / 8GB+256GB 柔光版 / 12GB+256GB 柔光版", + "颜色": "黑色 / 冰晶蓝 / 松石绿", + "卖点": "【至高享15%政府补贴,直播间下单赠保护壳,买赠价值655元游戏礼包】 第四代骁龙 8s 旗舰处理器 | 11.2英寸高清护眼屏 | 薄至5.75mm 轻至485g | 9200mAh 大电池 | 小米澎湃OS 3 | PC 级WPS、CAD", + "概述页": "https://www.mi.com/prod/xiaomi-pad-8", + "参数页": "https://www.mi.com/prod/xiaomi-pad-8/specs", + "来源": "小米商城 product_id=21872" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-cover.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-cover.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png" + ], + "imageCount": 59, + "galleryCount": 9, + "overviewCount": 50, + "overviewImgPath": null + }, + { + "productId": 22535, + "sku": "SKU-PAD-003", + "slug": "redmi-pad-2-pro", + "name": "REDMI Pad 2 Pro", + "unitPrice": 1999, + "description": "【至高享15%政府补贴,下单赠官方保护膜】 2.5K超清护眼屏 | 12000mAh超大容量电池 | 第四代骁龙7s平台 | PC级办公体验 | 跨端生态高效互联 | 小米教育中心", + "brand": "REDMI", + "salesCount": 34535, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "12.1英寸", + "highlight": true + }, + { + "name": "CPU", + "value": "骁龙 7s Gen4", + "highlight": true + }, + { + "name": "运行内存", + "value": "8GB/12GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "128GB/256GB", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2560*1600", + "highlight": true + }, + { + "name": "屏幕比例", + "value": "16:10", + "highlight": false + }, + { + "name": "屏幕刷新率", + "value": "120Hz", + "highlight": true + }, + { + "name": "屏幕类型", + "value": "屏幕比例", + "highlight": false, + "source": "specs-page" + }, + { + "name": "网络连接", + "value": "Wi-Fi 6", + "highlight": true + }, + { + "name": "是否支持IPv6", + "value": "支持", + "highlight": false + }, + { + "name": "电池容量", + "value": "12000mAh", + "highlight": true + }, + { + "name": "后置摄像头", + "value": "800W", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "800W", + "highlight": true + }, + { + "name": "操作系统", + "value": "Xiaomi HyperOS 3", + "highlight": false + }, + { + "name": "杜比视界", + "value": "支持", + "highlight": false + }, + { + "name": "护眼模式", + "value": "支持", + "highlight": false + }, + { + "name": "USB接口", + "value": "USB Type-C", + "highlight": false + }, + { + "name": "音频接口", + "value": "3.5mm耳机接口", + "highlight": false + }, + { + "name": "厚度", + "value": "重量", + "highlight": false, + "source": "specs-page" + }, + { + "name": "有线快充", + "value": "33W", + "highlight": false + }, + { + "name": "无线快充", + "value": "不支持", + "highlight": false + }, + { + "name": "是否支持专用触控笔", + "value": "支持", + "highlight": false + }, + { + "name": "是否支持专用键盘", + "value": "支持", + "highlight": false + }, + { + "name": "扬声器数量", + "value": "哈利·波特套装", + "highlight": false, + "source": "specs-page" + }, + { + "name": "2.5K 超清护眼屏", + "value": "12000mAh 超大容量电池", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小米青山护眼平板", + "value": "第四代骁龙 7s 平台", + "highlight": false, + "source": "specs-page" + }, + { + "name": "三重 TÜV 莱茵护眼认证", + "value": "小米澎湃OS 3", + "highlight": false, + "source": "specs-page" + }, + { + "name": "PC 级 WPS / CAJ", + "value": "小米教育中心丰富学习资源", + "highlight": false, + "source": "specs-page" + }, + { + "name": "279.79mm", + "value": "181.65mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "宽度", + "value": "厚度", + "highlight": false, + "source": "specs-page" + }, + { + "name": "7.5mm", + "value": "279.79mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "重量", + "value": "宽度", + "highlight": false, + "source": "specs-page" + }, + { + "name": "八核处理器,最高主频可达 2.7GHz", + "value": "810 GPU 图形处理器", + "highlight": false, + "source": "specs-page" + }, + { + "name": "GPU", + "value": "NPU", + "highlight": false, + "source": "specs-page" + }, + { + "name": "249", + "value": "LCD", + "highlight": false, + "source": "specs-page" + }, + { + "name": "16:10", + "value": "最高 120 Hz", + "highlight": false, + "source": "specs-page" + }, + { + "name": "刷新率", + "value": "手指触控采样率", + "highlight": false, + "source": "specs-page" + }, + { + "name": "最高 360 Hz", + "value": "最高 240 Hz", + "highlight": false, + "source": "specs-page" + }, + { + "name": "触控笔触控采样率", + "value": "全局最高亮度", + "highlight": false, + "source": "specs-page" + }, + { + "name": "600 nits", + "value": "CORNING® GORILLA® GLASS 3", + "highlight": false, + "source": "specs-page" + }, + { + "name": "玻璃盖板", + "value": "认证与测试", + "highlight": false, + "source": "specs-page" + }, + { + "name": "(min)", + "value": "33W 有线快充", + "highlight": false, + "source": "specs-page" + }, + { + "name": "内置双电芯高能量密度电池,不可拆卸", + "value": "支持 QC3+ / QC3.0 / QC2.0 / PD3.0 / PD2.0 快充协议", + "highlight": false, + "source": "specs-page" + }, + { + "name": "2个", + "value": "(含三包凭证)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "支持中国联通 eSIM", + "value": "支持双卡双 5G", + "highlight": false, + "source": "specs-page" + }, + { + "name": "181.65mm", + "value": "7.5mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "720P 视频拍摄", + "value": "拍照模式|录像模式|⽂档模式|⾝份证影印模式|HDR|拍照美颜|拍照滤镜|声控拍照|拍照全屏画幅|", + "highlight": false, + "source": "specs-page" + }, + { + "name": "30/60fps", + "value": "30fps", + "highlight": false, + "source": "specs-page" + }, + { + "name": "Bluetooth 5.4", + "value": "支持双卡双5G,全网通(支持数据/短信/语音功能)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "支持 IPv6", + "value": "支持中国联通 eSIM(支持数据)", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "2.5K 超清护眼屏", + "value": "12000mAh 超大容量电池" + }, + { + "name": "小米青山护眼平板", + "value": "第四代骁龙 7s 平台" + }, + { + "name": "三重 TÜV 莱茵护眼认证", + "value": "搭载小米澎湃 OS 3" + }, + { + "name": "PC 级 WPS / CAJ", + "value": "小米教育中心丰富学习资源" + } + ] + }, + { + "items": [ + { + "name": "279.79mm", + "value": "181.65mm" + }, + { + "name": "宽度", + "value": "厚度" + } + ] + }, + { + "items": [ + { + "name": "7.5mm", + "value": "台积电 4nm 工艺制程" + }, + { + "name": "重量", + "value": "CPU" + } + ] + }, + { + "items": [ + { + "name": "八核处理器,最高主频可达 2.7GHz", + "value": "810 GPU 图形处理器" + }, + { + "name": "GPU", + "value": "NPU" + } + ] + }, + { + "items": [ + { + "name": "249", + "value": "LCD" + }, + { + "name": "屏幕类型", + "value": "屏幕比例" + } + ] + }, + { + "items": [ + { + "name": "16:10", + "value": "最高 120 Hz" + }, + { + "name": "刷新率", + "value": "手指触控采样率" + } + ] + }, + { + "items": [ + { + "name": "最高 360 Hz", + "value": "最高 240 Hz" + }, + { + "name": "触控笔触控采样率", + "value": "全局最高亮度" + } + ] + }, + { + "items": [ + { + "name": "600 nits", + "value": "CORNING® GORILLA® GLASS 3" + }, + { + "name": "玻璃盖板", + "value": "认证与测试" + } + ] + }, + { + "items": [ + { + "name": "(min)", + "value": "33W 有线快充" + }, + { + "name": "内置双电芯高能量密度电池,不可拆卸", + "value": "支持 QC3+ / QC3.0 / QC2.0 / PD3.0 / PD2.0 快充协议" + } + ] + }, + { + "items": [ + { + "name": "2个", + "value": "(含三包凭证)" + }, + { + "name": "扬声器数量", + "value": "哈利·波特套装" + } + ] + }, + { + "items": [ + { + "name": "2.5K 超清护眼屏", + "value": "12000mAh 超大容量电池" + }, + { + "name": "小米青山护眼平板", + "value": "第四代骁龙 7s 平台" + }, + { + "name": "三重 TÜV 莱茵护眼认证", + "value": "小米澎湃OS 3" + }, + { + "name": "PC 级 WPS / CAJ", + "value": "小米教育中心丰富学习资源" + }, + { + "name": "支持中国联通 eSIM", + "value": "支持双卡双 5G" + } + ] + }, + { + "items": [ + { + "name": "279.79mm", + "value": "181.65mm" + }, + { + "name": "宽度", + "value": "厚度" + } + ] + }, + { + "items": [ + { + "name": "7.5mm", + "value": "279.79mm" + }, + { + "name": "重量", + "value": "宽度" + } + ] + }, + { + "items": [ + { + "name": "181.65mm", + "value": "7.5mm" + }, + { + "name": "厚度", + "value": "重量" + } + ] + }, + { + "items": [ + { + "name": "720P 视频拍摄", + "value": "拍照模式|录像模式|⽂档模式|⾝份证影印模式|HDR|拍照美颜|拍照滤镜|声控拍照|拍照全屏画幅|" + } + ] + }, + { + "items": [ + { + "name": "30/60fps", + "value": "30fps" + }, + { + "name": "720P 视频拍摄", + "value": "拍照模式|录像模式|⽂档模式|⾝份证影印模式|HDR|拍照美颜|拍照滤镜|声控拍照|拍照全屏画幅|" + } + ] + }, + { + "items": [ + { + "name": "Bluetooth 5.4", + "value": "支持双卡双5G,全网通(支持数据/短信/语音功能)" + }, + { + "name": "支持 IPv6", + "value": "支持中国联通 eSIM(支持数据)" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-pad-2-pro/specs", + "buyPageParameterCount": 24, + "specsPageParameterCount": 29, + "specifications": { + "249": "LCD", + "屏幕尺寸": "12.1英寸", + "CPU": "骁龙 7s Gen4", + "运行内存": "8GB/12GB", + "内存容量": "128GB/256GB", + "屏幕分辨率": "2560*1600", + "屏幕比例": "16:10", + "屏幕刷新率": "120Hz", + "屏幕类型": "屏幕比例", + "网络连接": "Wi-Fi 6", + "是否支持IPv6": "支持", + "电池容量": "12000mAh", + "后置摄像头": "800W", + "前置摄像头": "800W", + "操作系统": "Xiaomi HyperOS 3", + "杜比视界": "支持", + "护眼模式": "支持", + "USB接口": "USB Type-C", + "音频接口": "3.5mm耳机接口", + "厚度": "重量", + "有线快充": "33W", + "无线快充": "不支持", + "是否支持专用触控笔": "支持", + "是否支持专用键盘": "支持", + "扬声器数量": "哈利·波特套装", + "2.5K 超清护眼屏": "12000mAh 超大容量电池", + "小米青山护眼平板": "第四代骁龙 7s 平台", + "三重 TÜV 莱茵护眼认证": "小米澎湃OS 3", + "PC 级 WPS / CAJ": "小米教育中心丰富学习资源", + "279.79mm": "181.65mm", + "宽度": "厚度", + "7.5mm": "279.79mm", + "重量": "宽度", + "八核处理器,最高主频可达 2.7GHz": "810 GPU 图形处理器", + "GPU": "NPU", + "16:10": "最高 120 Hz", + "刷新率": "手指触控采样率", + "最高 360 Hz": "最高 240 Hz", + "触控笔触控采样率": "全局最高亮度", + "600 nits": "CORNING® GORILLA® GLASS 3", + "玻璃盖板": "认证与测试", + "(min)": "33W 有线快充", + "内置双电芯高能量密度电池,不可拆卸": "支持 QC3+ / QC3.0 / QC2.0 / PD3.0 / PD2.0 快充协议", + "2个": "(含三包凭证)", + "支持中国联通 eSIM": "支持双卡双 5G", + "181.65mm": "7.5mm", + "720P 视频拍摄": "拍照模式|录像模式|⽂档模式|⾝份证影印模式|HDR|拍照美颜|拍照滤镜|声控拍照|拍照全屏画幅|", + "30/60fps": "30fps", + "Bluetooth 5.4": "支持双卡双5G,全网通(支持数据/短信/语音功能)", + "支持 IPv6": "支持中国联通 eSIM(支持数据)", + "颜色": "哈利·波特版 / 深灰色 / 紫色 / 银色 / 乐学版", + "版本": "8GB+256GB / 8GB+128GB / 8GB+256GB / 12GB+256GB 柔光版 / 8GB+128GB", + "卖点": "【至高享15%政府补贴,下单赠官方保护膜】 2.5K超清护眼屏 | 12000mAh超大容量电池 | 第四代骁龙7s平台 | PC级办公体验 | 跨端生态高效互联 | 小米教育中心", + "概述页": "https://www.mi.com/prod/redmi-pad-2-pro", + "参数页": "https://www.mi.com/prod/redmi-pad-2-pro/specs", + "来源": "小米商城 product_id=22535" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.png", + "gallery/08-detail.png", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png" + ], + "imageCount": 13, + "galleryCount": 9, + "overviewCount": 4, + "overviewImgPath": null + }, + { + "productId": 21591, + "sku": "SKU-PAD-004", + "slug": "redmi-pad-2", + "name": "REDMI Pad 2", + "unitPrice": 1299, + "description": "【至高15%政府补贴,柔光版下单赠保护壳】 2.5K超清护眼屏 | 9000mAh超长续航大电池 | 海量题库精准辅学 | 千元高性价比大屏平板", + "brand": "REDMI", + "salesCount": 33591, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "11英寸", + "highlight": true + }, + { + "name": "CPU", + "value": "G100-Ultra", + "highlight": true + }, + { + "name": "运行内存", + "value": "6GB/8GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "128GB/256GB", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2.5k", + "highlight": true + }, + { + "name": "屏幕比例", + "value": "16:10", + "highlight": false + }, + { + "name": "屏幕刷新率", + "value": "90Hz", + "highlight": true + }, + { + "name": "屏幕类型", + "value": "LCD", + "highlight": false + }, + { + "name": "网络连接", + "value": "WI-FI 5", + "highlight": true + }, + { + "name": "是否支持IPv6", + "value": "支持", + "highlight": false + }, + { + "name": "电池容量", + "value": "9000mAh", + "highlight": true + }, + { + "name": "操作系统", + "value": "HyperOS 2", + "highlight": false + }, + { + "name": "护眼模式", + "value": "支持", + "highlight": false + }, + { + "name": "扬声器数量", + "value": "4个", + "highlight": false + }, + { + "name": "3.5mm", + "value": "内存: 6GB / 8GB LPDDR4X 内存", + "highlight": false, + "source": "specs-page" + }, + { + "name": "耳机接口", + "value": "存储: 128GB / 256GB UFS 2.2 储存", + "highlight": false, + "source": "specs-page" + }, + { + "name": "1080P 视频拍摄:30fps", + "value": "MP3,FLAC,APE,AAC,WAV,AMR", + "highlight": false, + "source": "specs-page" + }, + { + "name": "720P 视频拍摄:30fps", + "value": "MP4,MKV,", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "3.5mm", + "value": "内存: 6GB / 8GB LPDDR4X 内存" + }, + { + "name": "耳机接口", + "value": "存储: 128GB / 256GB UFS 2.2 储存" + } + ] + }, + { + "items": [ + { + "name": "1080P 视频拍摄:30fps", + "value": "MP3,FLAC,APE,AAC,WAV,AMR" + }, + { + "name": "720P 视频拍摄:30fps", + "value": "MP4,MKV," + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-pad-2/specs", + "buyPageParameterCount": 14, + "specsPageParameterCount": 4, + "specifications": { + "屏幕尺寸": "11英寸", + "CPU": "G100-Ultra", + "运行内存": "6GB/8GB", + "内存容量": "128GB/256GB", + "屏幕分辨率": "2.5k", + "屏幕比例": "16:10", + "屏幕刷新率": "90Hz", + "屏幕类型": "LCD", + "网络连接": "WI-FI 5", + "是否支持IPv6": "支持", + "电池容量": "9000mAh", + "操作系统": "HyperOS 2", + "护眼模式": "支持", + "扬声器数量": "4个", + "3.5mm": "内存: 6GB / 8GB LPDDR4X 内存", + "耳机接口": "存储: 128GB / 256GB UFS 2.2 储存", + "1080P 视频拍摄:30fps": "MP3,FLAC,APE,AAC,WAV,AMR", + "720P 视频拍摄:30fps": "MP4,MKV,", + "版本": "6GB+128GB / 乐学版 6GB+128GB / 8GB+128GB / 8GB+256GB / 柔光版 8GB+128GB / 柔光版 8GB+256GB", + "颜色": "灰色 / 紫色 / 青色", + "卖点": "【至高15%政府补贴,柔光版下单赠保护壳】 2.5K超清护眼屏 | 9000mAh超长续航大电池 | 海量题库精准辅学 | 千元高性价比大屏平板", + "概述页": "https://www.mi.com/prod/redmi-pad-2", + "参数页": "https://www.mi.com/prod/redmi-pad-2/specs", + "来源": "小米商城 product_id=21591" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png" + ], + "imageCount": 51, + "galleryCount": 9, + "overviewCount": 42, + "overviewImgPath": null + }, + { + "productId": 24235, + "sku": "SKU-PAD-005", + "slug": "redmi-pad-2-se", + "name": "REDMI Pad 2 SE", + "unitPrice": 1199, + "description": "【至高享15%政府补贴,购机赠双面保护壳!】 2K高素质屏幕|旗舰级金属机身|70.2天超长待机", + "brand": "REDMI", + "salesCount": 36235, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "9.7英寸", + "highlight": true + }, + { + "name": "CPU", + "value": "第二代骁龙6s 4G 处理器", + "highlight": true + }, + { + "name": "运行内存", + "value": "6GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "128GB", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2048*1280", + "highlight": true + }, + { + "name": "屏幕刷新率", + "value": "最高120Hz", + "highlight": true + }, + { + "name": "屏幕类型", + "value": "LCD", + "highlight": false + }, + { + "name": "是否支持IPv6", + "value": "支持", + "highlight": false + }, + { + "name": "电池容量", + "value": "7600mAh", + "highlight": true + }, + { + "name": "后置摄像头", + "value": "800W", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "500W", + "highlight": true + }, + { + "name": "操作系统", + "value": "Xiaomi HyperOS 3(Android 16)", + "highlight": false + }, + { + "name": "USB接口", + "value": "支持", + "highlight": false + }, + { + "name": "音频接口", + "value": "3.5mm耳机接口", + "highlight": false + }, + { + "name": "厚度", + "value": "7.4mm", + "highlight": false + }, + { + "name": "是否支持专用触控笔", + "value": "支持被动笔(REDMI 涂鸦触控笔)", + "highlight": false + }, + { + "name": "是否支持专用键盘", + "value": "支持有线外接键盘 (无官方键盘保护壳)", + "highlight": false + }, + { + "name": "扬声器数量", + "value": "2个", + "highlight": false + }, + { + "name": "1080P 视频拍摄:30fps", + "value": "拍照模式⼁录像模式⼁⽂档模式|⾝份证影印模式|HDR| 拍照美颜|拍照滤镜|声控拍照|拍照全屏", + "highlight": false, + "source": "specs-page" + }, + { + "name": "720P 视频拍摄:30fps", + "value": "画幅|倒计时拍照|视频美颜|提词器|⾃定义⽔印|⾃动夜景", + "highlight": false, + "source": "specs-page" + }, + { + "name": "内存:6GB LPDDR4X 内存", + "value": "* 实际可用容量会由于诸多因素而减少并有所差异:由于操作系统运行占据了部分内存(RAM),实际可用空间小于", + "highlight": false, + "source": "specs-page" + }, + { + "name": "存储:128GB UFS 2.2 储存", + "value": "标识内存容量;由于安装操作系统和预装的程序占据了部分闪存(ROM),实际可用存储空间小于标识闪存容量。", + "highlight": false, + "source": "specs-page" + }, + { + "name": "重量:401g", + "value": "重量:406g", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "1080P 视频拍摄:30fps", + "value": "拍照模式⼁录像模式⼁⽂档模式|⾝份证影印模式|HDR| 拍照美颜|拍照滤镜|声控拍照|拍照全屏" + }, + { + "name": "720P 视频拍摄:30fps", + "value": "画幅|倒计时拍照|视频美颜|提词器|⾃定义⽔印|⾃动夜景" + } + ] + }, + { + "items": [ + { + "name": "内存:6GB LPDDR4X 内存", + "value": "* 实际可用容量会由于诸多因素而减少并有所差异:由于操作系统运行占据了部分内存(RAM),实际可用空间小于" + }, + { + "name": "存储:128GB UFS 2.2 储存", + "value": "标识内存容量;由于安装操作系统和预装的程序占据了部分闪存(ROM),实际可用存储空间小于标识闪存容量。" + } + ] + }, + { + "items": [ + { + "name": "重量:401g", + "value": "重量:406g" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-pad-2-se/specs", + "buyPageParameterCount": 18, + "specsPageParameterCount": 5, + "specifications": { + "屏幕尺寸": "9.7英寸", + "CPU": "第二代骁龙6s 4G 处理器", + "运行内存": "6GB", + "内存容量": "128GB", + "屏幕分辨率": "2048*1280", + "屏幕刷新率": "最高120Hz", + "屏幕类型": "LCD", + "是否支持IPv6": "支持", + "电池容量": "7600mAh", + "后置摄像头": "800W", + "前置摄像头": "500W", + "操作系统": "Xiaomi HyperOS 3(Android 16)", + "USB接口": "支持", + "音频接口": "3.5mm耳机接口", + "厚度": "7.4mm", + "是否支持专用触控笔": "支持被动笔(REDMI 涂鸦触控笔)", + "是否支持专用键盘": "支持有线外接键盘 (无官方键盘保护壳)", + "扬声器数量": "2个", + "1080P 视频拍摄:30fps": "拍照模式⼁录像模式⼁⽂档模式|⾝份证影印模式|HDR| 拍照美颜|拍照滤镜|声控拍照|拍照全屏", + "720P 视频拍摄:30fps": "画幅|倒计时拍照|视频美颜|提词器|⾃定义⽔印|⾃动夜景", + "内存:6GB LPDDR4X 内存": "* 实际可用容量会由于诸多因素而减少并有所差异:由于操作系统运行占据了部分内存(RAM),实际可用空间小于", + "存储:128GB UFS 2.2 储存": "标识内存容量;由于安装操作系统和预装的程序占据了部分闪存(ROM),实际可用存储空间小于标识闪存容量。", + "重量:401g": "重量:406g", + "颜色": "银色 / 深灰色 / 深灰色 4G", + "通用": "通用", + "卖点": "【至高享15%政府补贴,购机赠双面保护壳!】 2K高素质屏幕|旗舰级金属机身|70.2天超长待机", + "概述页": "https://www.mi.com/prod/redmi-pad-2-se", + "参数页": "https://www.mi.com/prod/redmi-pad-2-se/specs", + "来源": "小米商城 product_id=24235" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png" + ], + "imageCount": 37, + "galleryCount": 9, + "overviewCount": 28, + "overviewImgPath": null + }, + { + "productId": 23974, + "sku": "SKU-PAD-006", + "slug": "redmi-k-pad-2", + "name": "REDMI K Pad 2", + "unitPrice": 3599, + "description": "【至高享15%政府补贴,下单再享至高6期免息!】 天玑 9500 正代旗舰芯 | 8.8\" 3K LCD 高亮电竞屏 | SoC 中置散热架构 | 15300mm² 铝合金液冷VC", + "brand": "REDMI", + "salesCount": 35974, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "8.8英寸", + "highlight": true + }, + { + "name": "CPU", + "value": "天玑9500", + "highlight": true + }, + { + "name": "运行内存", + "value": "8GB/12GB/16GB", + "highlight": true + }, + { + "name": "内存容量", + "value": "256GB/512GB", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "3008 * 1880", + "highlight": true + }, + { + "name": "屏幕刷新率", + "value": "最高165Hz", + "highlight": true + }, + { + "name": "屏幕类型", + "value": "LCD", + "highlight": false + }, + { + "name": "是否支持IPv6", + "value": "支持", + "highlight": false + }, + { + "name": "电池容量", + "value": "9100mAh", + "highlight": true + }, + { + "name": "后置摄像头", + "value": "1300W", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "800W", + "highlight": true + }, + { + "name": "操作系统", + "value": "Xiaomi HyperOS 3 (Android 16)", + "highlight": false + }, + { + "name": "杜比视界", + "value": "支持", + "highlight": false + }, + { + "name": "护眼模式", + "value": "支持", + "highlight": false + }, + { + "name": "USB接口", + "value": "支持", + "highlight": false + }, + { + "name": "音频接口", + "value": "Type-C接口", + "highlight": false + }, + { + "name": "厚度", + "value": "6.62mm", + "highlight": false + }, + { + "name": "有线快充", + "value": "快充协议支持情况:\n支持PD3.0 / PD2.0 快充协议 / 67W小米澎湃秒充W", + "highlight": false + }, + { + "name": "无线快充", + "value": "不支持", + "highlight": false + }, + { + "name": "是否支持专用触控笔", + "value": "支持REDMI 灵感触控笔/小米焦点触控笔/小米焦点触控笔 Pro", + "highlight": false + }, + { + "name": "是否支持专用键盘", + "value": "无键盘", + "highlight": false + }, + { + "name": "扬声器数量", + "value": "2个", + "highlight": false + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-k-pad-2/specs", + "buyPageParameterCount": 22, + "specsPageParameterCount": 0, + "specifications": { + "屏幕尺寸": "8.8英寸", + "CPU": "天玑9500", + "运行内存": "8GB/12GB/16GB", + "内存容量": "256GB/512GB", + "屏幕分辨率": "3008 * 1880", + "屏幕刷新率": "最高165Hz", + "屏幕类型": "LCD", + "是否支持IPv6": "支持", + "电池容量": "9100mAh", + "后置摄像头": "1300W", + "前置摄像头": "800W", + "操作系统": "Xiaomi HyperOS 3 (Android 16)", + "杜比视界": "支持", + "护眼模式": "支持", + "USB接口": "支持", + "音频接口": "Type-C接口", + "厚度": "6.62mm", + "有线快充": "快充协议支持情况:\n支持PD3.0 / PD2.0 快充协议 / 67W小米澎湃秒充W", + "无线快充": "不支持", + "是否支持专用触控笔": "支持REDMI 灵感触控笔/小米焦点触控笔/小米焦点触控笔 Pro", + "是否支持专用键盘": "无键盘", + "扬声器数量": "2个", + "颜色": "深邃黑 / 太空银 / 电光紫", + "版本": "8GB+256GB / 12GB+256GB / 16GB+256GB / 16GB+512GB", + "卖点": "【至高享15%政府补贴,下单再享至高6期免息!】 天玑 9500 正代旗舰芯 | 8.8\" 3K LCD 高亮电竞屏 | SoC 中置散热架构 | 15300mm² 铝合金液冷VC", + "概述页": "https://www.mi.com/prod/redmi-k-pad-2", + "来源": "小米商城 product_id=23974" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.jpg", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.jpg", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 21936, + "sku": "SKU-PHONE-001", + "slug": "xiaomi-17-pro-max", + "name": "Xiaomi 17 Pro Max", + "unitPrice": 6499, + "description": "徕卡光影大师 移动影像系统|徕卡 5X 超聚光潜望长焦|妙享背屏|第五代 骁龙 8 至尊版移动平台|7500mAh 小米金沙江电池|低功耗超级阳光屏 「超级像素」", + "brand": "Xiaomi", + "salesCount": 33936, + "parameterList": [ + { + "name": "CPU型号", + "value": "第五代骁龙®8至尊版移动平台", + "highlight": true + }, + { + "name": "CPU主频", + "value": "最高4.6GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素+5000万像素+5000万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "5000万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "AMOLED", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "正屏:6.9英寸|背屏:2.9英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "正屏:2608x1200|背屏:976x596", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高16GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高1024GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "3D超声波指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.0mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "7500mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "100W", + "highlight": false + }, + { + "name": "无线快充", + "value": "50W", + "highlight": false + }, + { + "name": "网络类型", + "value": "5G全网通", + "highlight": true + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "GPU 图形处理器", + "value": "*Xiaomi 17 Pro Max 显示屏采用曲线优美的圆角设计,四个圆角位于一个标准矩形内。按照标准矩形测量时,正屏对角线长度是 6.9 英寸、背屏对角线", + "highlight": false, + "source": "specs-page" + }, + { + "name": "AI:高通 AI 引擎", + "value": "长度是 2.9 英寸 (实际可视区域较小)。", + "highlight": false, + "source": "specs-page" + }, + { + "name": "支持双卡双通,双卡高清语音", + "value": "支持频段", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "GPU 图形处理器", + "value": "*Xiaomi 17 Pro Max 显示屏采用曲线优美的圆角设计,四个圆角位于一个标准矩形内。按照标准矩形测量时,正屏对角线长度是 6.9 英寸、背屏对角线" + }, + { + "name": "AI:高通 AI 引擎", + "value": "长度是 2.9 英寸 (实际可视区域较小)。" + } + ] + }, + { + "items": [ + { + "name": "支持双卡双通,双卡高清语音", + "value": "支持频段" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-17-pro-max/specs", + "buyPageParameterCount": 19, + "specsPageParameterCount": 3, + "specifications": { + "CPU型号": "第五代骁龙®8至尊版移动平台", + "CPU主频": "最高4.6GHz", + "后置摄像头": "5000万像素+5000万像素+5000万像素", + "前置摄像头": "5000万像素", + "屏幕": "AMOLED", + "屏幕尺寸": "正屏:6.9英寸|背屏:2.9英寸", + "屏幕分辨率": "正屏:2608x1200|背屏:976x596", + "运行内存": "最高16GB", + "存储容量": "最高1024GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "3D超声波指纹", + "机身厚度": "8.0mm", + "电池容量": "7500mAh", + "有线快充": "100W", + "无线快充": "50W", + "网络类型": "5G全网通", + "网络模式": "双卡双待", + "数据接口": "Type-C", + "GPU 图形处理器": "*Xiaomi 17 Pro Max 显示屏采用曲线优美的圆角设计,四个圆角位于一个标准矩形内。按照标准矩形测量时,正屏对角线长度是 6.9 英寸、背屏对角线", + "AI:高通 AI 引擎": "长度是 2.9 英寸 (实际可视区域较小)。", + "支持双卡双通,双卡高清语音": "支持频段", + "版本": "12GB+512GB / 16GB+512GB / 16GB+1TB", + "颜色": "黑色 / 白色 / 冷烟紫 / 森野绿", + "卖点": "徕卡光影大师 移动影像系统|徕卡 5X 超聚光潜望长焦|妙享背屏|第五代 骁龙 8 至尊版移动平台|7500mAh 小米金沙江电池|低功耗超级阳光屏 「超级像素」", + "概述页": "https://www.mi.com/prod/xiaomi-17-pro-max", + "参数页": "https://www.mi.com/prod/xiaomi-17-pro-max/specs", + "来源": "小米商城 product_id=21936" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-gallery.png", + "gallery/05-cover.png", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.jpeg", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png", + "overview/053-overview.png", + "overview/054-overview.png", + "overview/055-overview.png", + "overview/056-overview.png", + "overview/057-overview.png", + "overview/058-overview.png", + "overview/059-overview.png", + "overview/060-overview.png", + "overview/061-overview.png", + "overview/062-overview.png", + "overview/063-overview.png", + "overview/064-overview.png", + "overview/065-overview.png", + "overview/066-overview.png", + "overview/067-overview.png", + "overview/068-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-gallery.png", + "gallery/05-cover.png", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.jpeg", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png", + "overview/053-overview.png", + "overview/054-overview.png", + "overview/055-overview.png", + "overview/056-overview.png", + "overview/057-overview.png", + "overview/058-overview.png", + "overview/059-overview.png", + "overview/060-overview.png", + "overview/061-overview.png", + "overview/062-overview.png", + "overview/063-overview.png", + "overview/064-overview.png", + "overview/065-overview.png", + "overview/066-overview.png", + "overview/067-overview.png", + "overview/068-overview.png" + ], + "imageCount": 78, + "galleryCount": 9, + "overviewCount": 69, + "overviewImgPath": null + }, + { + "productId": 22423, + "sku": "SKU-PHONE-002", + "slug": "xiaomi-17-ultra", + "name": "Xiaomi 17 Ultra 徕卡版", + "unitPrice": 7999, + "description": "经典双拼设计|中框精雕滚花|徕卡经典红标设计|大师变焦环|经典3:2全画幅比例|徕卡M3画质|徕卡M9画质|安全隐私加密芯片|徕卡一英寸光影大师|光影猎人1050L传感器|LOFIC超高动态技术|烟花模式|徕卡超纯光学系统|徕卡2亿像素光学变焦|2亿像素超大底传感器|75mm-100mm 2亿像素光学直出|8片3组 光学架构|4K 实况运镜|红毯运镜|徕卡APO光学认证|50MP 徕卡超广角|超级像素|1.18mm 极窄四等边|M10 新一代屏幕发光材料|", + "brand": "Xiaomi", + "salesCount": 34423, + "parameterList": [ + { + "name": "CPU型号", + "value": "第五代骁龙8至尊版移动平台", + "highlight": true + }, + { + "name": "CPU主频", + "value": "最高4.6GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素+2亿像素+5000万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "5000万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "AMOLED", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.9英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2608 x 1200", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高16GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高1024GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "3D超声波指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.46mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "6800mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "90W", + "highlight": false + }, + { + "name": "无线快充", + "value": "50W", + "highlight": false + }, + { + "name": "网络类型", + "value": "5G全网通", + "highlight": true + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "支持移动 / 联通 / 电信 / 广电", + "value": "支持双卡双通,双卡高清语音", + "highlight": false, + "source": "specs-page" + }, + { + "name": "前置5000万高清相机", + "value": "50MP丨ƒ/2.2大光圈丨90°广视角丨AF自动对焦", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "支持移动 / 联通 / 电信 / 广电", + "value": "支持双卡双通,双卡高清语音" + } + ] + }, + { + "items": [ + { + "name": "前置5000万高清相机", + "value": "50MP丨ƒ/2.2大光圈丨90°广视角丨AF自动对焦" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-17-ultra/specs", + "buyPageParameterCount": 19, + "specsPageParameterCount": 2, + "specifications": { + "CPU型号": "第五代骁龙8至尊版移动平台", + "CPU主频": "最高4.6GHz", + "后置摄像头": "5000万像素+2亿像素+5000万像素", + "前置摄像头": "5000万像素", + "屏幕": "AMOLED", + "屏幕尺寸": "6.9英寸", + "屏幕分辨率": "2608 x 1200", + "运行内存": "最高16GB", + "存储容量": "最高1024GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "3D超声波指纹", + "机身厚度": "8.46mm", + "电池容量": "6800mAh", + "有线快充": "90W", + "无线快充": "50W", + "网络类型": "5G全网通", + "网络模式": "双卡双待", + "数据接口": "Type-C", + "支持移动 / 联通 / 电信 / 广电": "支持双卡双通,双卡高清语音", + "前置5000万高清相机": "50MP丨ƒ/2.2大光圈丨90°广视角丨AF自动对焦", + "版本": "16GB+512GB / 16GB+1TB", + "颜色": "黑银色(非卫星) / 黑色 / 米白色", + "卖点": "经典双拼设计|中框精雕滚花|徕卡经典红标设计|大师变焦环|经典3:2全画幅比例|徕卡M3画质|徕卡M9画质|安全隐私加密芯片|徕卡一英寸光影大师|光影猎人1050L传感器|LOFIC超高动态技术|烟花模式|徕卡超纯光学系统|徕卡2亿像素光学变焦|2亿像素超大底传感器|75mm-100mm 2亿像素光学直出|8片3组 光学架构|4K 实况运镜|红毯运镜|徕卡APO光学认证|50MP 徕卡超广角|超级像素|1.18mm 极窄四等边|M10 ", + "概述页": "https://www.mi.com/prod/xiaomi-17-ultra", + "参数页": "https://www.mi.com/prod/xiaomi-17-ultra/specs", + "来源": "小米商城 product_id=22423" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.jpg", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.jpg", + "overview/007-overview.jpg", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.jpg", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.jpg", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.jpg", + "overview/023-overview.jpg", + "overview/024-overview.png", + "overview/025-overview.jpg", + "overview/026-overview.jpg", + "overview/027-overview.jpg", + "overview/028-overview.jpg", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.jpg", + "overview/037-overview.jpg", + "overview/038-overview.jpg", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.jpg", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.jpg", + "overview/053-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.jpg", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.jpg", + "overview/007-overview.jpg", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.jpg", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.jpg", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.jpg", + "overview/023-overview.jpg", + "overview/024-overview.png", + "overview/025-overview.jpg", + "overview/026-overview.jpg", + "overview/027-overview.jpg", + "overview/028-overview.jpg", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.jpg", + "overview/037-overview.jpg", + "overview/038-overview.jpg", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.jpg", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.jpg", + "overview/053-overview.png" + ], + "imageCount": 63, + "galleryCount": 9, + "overviewCount": 54, + "overviewImgPath": null + }, + { + "productId": 24930, + "sku": "SKU-PHONE-003", + "slug": "redmi-note-17-pro", + "name": "REDMI Note 17 Pro", + "unitPrice": 1599, + "description": "【直播间购机限量赠小背包】 抗摔 防水 长续航", + "brand": "REDMI", + "salesCount": 36930, + "parameterList": [ + { + "name": "CPU型号", + "value": "第四代骁龙®6s", + "highlight": true + }, + { + "name": "CPU主频", + "value": "最高2.4GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素+200万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "800万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "AMOLED", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.83英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2772x1280", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高12GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高512GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "屏下光学指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.46mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "9000mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "67W", + "highlight": false + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "金刚品质", + "value": "小米青山护眼", + "highlight": false, + "source": "specs-page" + }, + { + "name": "双金标五星品质认证", + "value": "9000mAh 小米金沙江电池", + "highlight": false, + "source": "specs-page" + }, + { + "name": "康宁®大猩猩®Victus®2", + "value": "67W 有线快充", + "highlight": false, + "source": "specs-page" + }, + { + "name": "IP66|IP68|IP69|IP69K", + "value": "22.5W 有线反充", + "highlight": false, + "source": "specs-page" + }, + { + "name": "防水大满贯", + "value": "立体声双扬声器", + "highlight": false, + "source": "specs-page" + }, + { + "name": "1.5K AMOLED 超级阳光屏", + "value": "Xiaomi HyperOS 3", + "highlight": false, + "source": "specs-page" + }, + { + "name": "67W 有线快充*", + "value": "补光灯|动态照片|美颜|滤镜|倒计时|水印|声控拍照|手势拍照|延时摄影|提词器|人像|人像美颜|视频美颜", + "highlight": false, + "source": "specs-page" + }, + { + "name": "22.5W 有线反充*", + "value": "视频滤镜", + "highlight": false, + "source": "specs-page" + }, + { + "name": "1080P 视频拍摄:30FPS", + "value": "4G:FDD-LTE:B1/B3/B5/B8/B19/B28A", + "highlight": false, + "source": "specs-page" + }, + { + "name": "720P 视频拍摄:30FPS", + "value": "TDD-LTE:B34/B38/B39/B40/B41/B42/B48", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "金刚品质", + "value": "小米青山护眼" + }, + { + "name": "双金标五星品质认证", + "value": "9000mAh 小米金沙江电池" + }, + { + "name": "康宁®大猩猩®Victus®2", + "value": "67W 有线快充" + }, + { + "name": "IP66|IP68|IP69|IP69K", + "value": "22.5W 有线反充" + }, + { + "name": "防水大满贯", + "value": "立体声双扬声器" + }, + { + "name": "1.5K AMOLED 超级阳光屏", + "value": "Xiaomi HyperOS 3" + } + ] + }, + { + "items": [ + { + "name": "67W 有线快充*", + "value": "补光灯|动态照片|美颜|滤镜|倒计时|水印|声控拍照|手势拍照|延时摄影|提词器|人像|人像美颜|视频美颜" + }, + { + "name": "22.5W 有线反充*", + "value": "视频滤镜" + } + ] + }, + { + "items": [ + { + "name": "1080P 视频拍摄:30FPS", + "value": "4G:FDD-LTE:B1/B3/B5/B8/B19/B28A" + }, + { + "name": "720P 视频拍摄:30FPS", + "value": "TDD-LTE:B34/B38/B39/B40/B41/B42/B48" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-note-17-pro/specs", + "buyPageParameterCount": 17, + "specsPageParameterCount": 10, + "specifications": { + "CPU型号": "第四代骁龙®6s", + "CPU主频": "最高2.4GHz", + "后置摄像头": "5000万像素+200万像素", + "前置摄像头": "800万像素", + "屏幕": "AMOLED", + "屏幕尺寸": "6.83英寸", + "屏幕分辨率": "2772x1280", + "运行内存": "最高12GB", + "存储容量": "最高512GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "屏下光学指纹", + "机身厚度": "8.46mm", + "电池容量": "9000mAh", + "有线快充": "67W", + "网络模式": "双卡双待", + "数据接口": "Type-C", + "金刚品质": "小米青山护眼", + "双金标五星品质认证": "9000mAh 小米金沙江电池", + "康宁®大猩猩®Victus®2": "67W 有线快充", + "IP66|IP68|IP69|IP69K": "22.5W 有线反充", + "防水大满贯": "立体声双扬声器", + "1.5K AMOLED 超级阳光屏": "Xiaomi HyperOS 3", + "67W 有线快充*": "补光灯|动态照片|美颜|滤镜|倒计时|水印|声控拍照|手势拍照|延时摄影|提词器|人像|人像美颜|视频美颜", + "22.5W 有线反充*": "视频滤镜", + "1080P 视频拍摄:30FPS": "4G:FDD-LTE:B1/B3/B5/B8/B19/B28A", + "720P 视频拍摄:30FPS": "TDD-LTE:B34/B38/B39/B40/B41/B42/B48", + "版本": "8GB+256GB / 8GB+128GB / 8GB+512GB / 12GB+256GB", + "颜色": "黑色 / 白色 / 晴空蓝 / 星云紫", + "卖点": "【直播间购机限量赠小背包】 抗摔 防水 长续航", + "概述页": "https://www.mi.com/prod/redmi-note-17-pro", + "参数页": "https://www.mi.com/prod/redmi-note-17-pro/specs", + "来源": "小米商城 product_id=24930" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-gallery.png", + "gallery/05-cover.png", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-gallery.png", + "gallery/05-cover.png", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png" + ], + "imageCount": 60, + "galleryCount": 9, + "overviewCount": 51, + "overviewImgPath": null + }, + { + "productId": 24940, + "sku": "SKU-PHONE-004", + "slug": "redmi-note-17", + "name": "REDMI Note 17", + "unitPrice": 1299, + "description": "【直播间购机限量赠小背包】 大屏 大电量", + "brand": "REDMI", + "salesCount": 36940, + "parameterList": [ + { + "name": "CPU型号", + "value": "第四代骁龙® 4", + "highlight": true + }, + { + "name": "CPU主频", + "value": "最高2.3GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "800万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "AMOLED", + "highlight": false + }, + { + "name": "屏幕分辨率", + "value": "2396x1080", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高8GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高256GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "屏下光学指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.26mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "8000mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "45W", + "highlight": false + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "1080P 视频拍摄:30FPS", + "value": "内置锂离子聚合物电池,不可拆卸", + "highlight": false, + "source": "specs-page" + }, + { + "name": "720P 视频拍摄:30FPS", + "value": "小米澎湃 G1 自研电量计芯片", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "1080P 视频拍摄:30FPS", + "value": "内置锂离子聚合物电池,不可拆卸" + }, + { + "name": "720P 视频拍摄:30FPS", + "value": "小米澎湃 G1 自研电量计芯片" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-note-17/specs", + "buyPageParameterCount": 15, + "specsPageParameterCount": 2, + "specifications": { + "CPU型号": "第四代骁龙® 4", + "CPU主频": "最高2.3GHz", + "后置摄像头": "5000万像素", + "前置摄像头": "800万像素", + "屏幕": "AMOLED", + "屏幕分辨率": "2396x1080", + "运行内存": "最高8GB", + "存储容量": "最高256GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "屏下光学指纹", + "机身厚度": "8.26mm", + "电池容量": "8000mAh", + "有线快充": "45W", + "网络模式": "双卡双待", + "1080P 视频拍摄:30FPS": "内置锂离子聚合物电池,不可拆卸", + "720P 视频拍摄:30FPS": "小米澎湃 G1 自研电量计芯片", + "版本": "8GB+128GB / 6GB+128GB / 8GB+256GB", + "颜色": "浅水青 / 黑色 / 流星紫", + "卖点": "【直播间购机限量赠小背包】 大屏 大电量", + "概述页": "https://www.mi.com/prod/redmi-note-17", + "参数页": "https://www.mi.com/prod/redmi-note-17/specs", + "来源": "小米商城 product_id=24940" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png" + ], + "imageCount": 48, + "galleryCount": 9, + "overviewCount": 39, + "overviewImgPath": null + }, + { + "productId": 24933, + "sku": "SKU-PHONE-005", + "slug": "redmi-k90-ultra", + "name": "REDMI K90 至尊版", + "unitPrice": 3299, + "description": "骁龙®8 至尊版|AI 游戏独显|风冷主动散热|165Hz 高刷电竞屏", + "brand": "REDMI", + "salesCount": 36933, + "parameterList": [ + { + "name": "CPU型号", + "value": "骁龙®8 至尊版", + "highlight": true + }, + { + "name": "CPU主频", + "value": "4.32GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素+800万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "2000万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "AMOLED", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.83英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2772*1280", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高16GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高512GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "超声波指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.18mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "8550mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "100W", + "highlight": false + }, + { + "name": "网络模式", + "value": "双卡双通", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "骁龙®8 至尊版", + "value": "全新对称双扬声器", + "highlight": false, + "source": "specs-page" + }, + { + "name": "AI 游戏独显 D2", + "value": "小米青山护眼 3.0", + "highlight": false, + "source": "specs-page" + }, + { + "name": "风冷主动散热", + "value": "f/1.68 超感光主摄", + "highlight": false, + "source": "specs-page" + }, + { + "name": "6000mm² 超大冷泵", + "value": "小米澎湃 T1+ 信号增强芯", + "highlight": false, + "source": "specs-page" + }, + { + "name": "165Hz 超高刷电竞屏", + "value": "IP66|IP68|IP69", + "highlight": false, + "source": "specs-page" + }, + { + "name": "8550mAh 小米金沙江电池", + "value": "Xiaomi HyperOS 3", + "highlight": false, + "source": "specs-page" + }, + { + "name": "内存:12GB/16GB", + "value": "长度:162.91mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "存储:256GB/512G", + "value": "宽度:77.93mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "LPDDR5X 内存", + "value": "厚度:8.18mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "UFS 4.1 高速存储", + "value": "重量:227g", + "highlight": false, + "source": "specs-page" + }, + { + "name": "内置锂离子聚合物电池,不可拆卸", + "value": "手机支持 PD2.0 / PD3.0 快充协议", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小米澎湃 P3 充电芯片", + "value": "USB Type-C 双面充电接口", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小米澎湃 G2 自研电量计芯片", + "value": "标配 100W 超低功耗氮化镓充电器,兼容 PD 充电协议", + "highlight": false, + "source": "specs-page" + }, + { + "name": "100W 有线快充*", + "value": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|", + "highlight": false, + "source": "specs-page" + }, + { + "name": "22.5W 有线反充*", + "value": "触屏拍照|参考线", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "骁龙®8 至尊版", + "value": "全新对称双扬声器" + }, + { + "name": "AI 游戏独显 D2", + "value": "小米青山护眼 3.0" + }, + { + "name": "风冷主动散热", + "value": "f/1.68 超感光主摄" + }, + { + "name": "6000mm² 超大冷泵", + "value": "小米澎湃 T1+ 信号增强芯" + }, + { + "name": "165Hz 超高刷电竞屏", + "value": "IP66|IP68|IP69" + }, + { + "name": "8550mAh 小米金沙江电池", + "value": "Xiaomi HyperOS 3" + } + ] + }, + { + "items": [ + { + "name": "内存:12GB/16GB", + "value": "长度:162.91mm" + }, + { + "name": "存储:256GB/512G", + "value": "宽度:77.93mm" + }, + { + "name": "LPDDR5X 内存", + "value": "厚度:8.18mm" + }, + { + "name": "UFS 4.1 高速存储", + "value": "重量:227g" + } + ] + }, + { + "items": [ + { + "name": "内置锂离子聚合物电池,不可拆卸", + "value": "手机支持 PD2.0 / PD3.0 快充协议" + }, + { + "name": "小米澎湃 P3 充电芯片", + "value": "USB Type-C 双面充电接口" + }, + { + "name": "小米澎湃 G2 自研电量计芯片", + "value": "标配 100W 超低功耗氮化镓充电器,兼容 PD 充电协议" + } + ] + }, + { + "items": [ + { + "name": "100W 有线快充*", + "value": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|" + }, + { + "name": "22.5W 有线反充*", + "value": "触屏拍照|参考线" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-k90-ultra/specs", + "buyPageParameterCount": 17, + "specsPageParameterCount": 15, + "specifications": { + "CPU型号": "骁龙®8 至尊版", + "CPU主频": "4.32GHz", + "后置摄像头": "5000万像素+800万像素", + "前置摄像头": "2000万像素", + "屏幕": "AMOLED", + "屏幕尺寸": "6.83英寸", + "屏幕分辨率": "2772*1280", + "运行内存": "最高16GB", + "存储容量": "最高512GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "超声波指纹", + "机身厚度": "8.18mm", + "电池容量": "8550mAh", + "有线快充": "100W", + "网络模式": "双卡双通", + "数据接口": "Type-C", + "骁龙®8 至尊版": "全新对称双扬声器", + "AI 游戏独显 D2": "小米青山护眼 3.0", + "风冷主动散热": "f/1.68 超感光主摄", + "6000mm² 超大冷泵": "小米澎湃 T1+ 信号增强芯", + "165Hz 超高刷电竞屏": "IP66|IP68|IP69", + "8550mAh 小米金沙江电池": "Xiaomi HyperOS 3", + "内存:12GB/16GB": "长度:162.91mm", + "存储:256GB/512G": "宽度:77.93mm", + "LPDDR5X 内存": "厚度:8.18mm", + "UFS 4.1 高速存储": "重量:227g", + "内置锂离子聚合物电池,不可拆卸": "手机支持 PD2.0 / PD3.0 快充协议", + "小米澎湃 P3 充电芯片": "USB Type-C 双面充电接口", + "小米澎湃 G2 自研电量计芯片": "标配 100W 超低功耗氮化镓充电器,兼容 PD 充电协议", + "100W 有线快充*": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|", + "22.5W 有线反充*": "触屏拍照|参考线", + "版本": "16GB+256GB / 12GB+256GB / 12GB+512GB / 16GB+512GB", + "颜色": "太空银 / 暗影黑 / 天际蓝", + "卖点": "骁龙®8 至尊版|AI 游戏独显|风冷主动散热|165Hz 高刷电竞屏", + "概述页": "https://www.mi.com/prod/redmi-k90-ultra", + "参数页": "https://www.mi.com/prod/redmi-k90-ultra/specs", + "来源": "小米商城 product_id=24933" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png", + "overview/053-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png", + "overview/053-overview.png" + ], + "imageCount": 63, + "galleryCount": 9, + "overviewCount": 54, + "overviewImgPath": null + }, + { + "productId": 23969, + "sku": "SKU-PHONE-006", + "slug": "redmi-k90-max", + "name": "REDMI K90 Max", + "unitPrice": 3499, + "description": "天玑 9500 正代旗舰芯|游戏独显|超强风冷主动散热|165Hz 超高刷电竞屏", + "brand": "REDMI", + "salesCount": 35969, + "parameterList": [ + { + "name": "CPU型号", + "value": "天玑 9500", + "highlight": true + }, + { + "name": "CPU主频", + "value": "4.21GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素+800万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "2000万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "AMOLED", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.83英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2772×1280", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高16GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高1024GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "超声波指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.18mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "8550mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "100W", + "highlight": false + }, + { + "name": "网络类型", + "value": "5G全网通", + "highlight": true + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "天玑 9500 旗舰芯", + "value": "全新对称双扬声器", + "highlight": false, + "source": "specs-page" + }, + { + "name": "AI 游戏独显 D2", + "value": "小米青山护眼 3.0", + "highlight": false, + "source": "specs-page" + }, + { + "name": "风冷主动散热", + "value": "f/1.68 超感光主摄", + "highlight": false, + "source": "specs-page" + }, + { + "name": "6000mm² 冰封冷泵", + "value": "小米澎湃 T1+ 信号增强芯", + "highlight": false, + "source": "specs-page" + }, + { + "name": "165Hz 超高刷电竞屏", + "value": "IP66|IP68|IP69", + "highlight": false, + "source": "specs-page" + }, + { + "name": "8550mAh 小米金沙江电池", + "value": "Xiaomi HyperOS 3", + "highlight": false, + "source": "specs-page" + }, + { + "name": "内存:12GB/16GB", + "value": "长度:162.91mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "存储:256GB/512GB/1T", + "value": "宽度:77.93mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "LPDDR5X 内存", + "value": "厚度:8.18mm", + "highlight": false, + "source": "specs-page" + }, + { + "name": "UFS 4.1 高速存储", + "value": "重量:227g", + "highlight": false, + "source": "specs-page" + }, + { + "name": "内置锂离子聚合物电池,不可拆卸", + "value": "手机支持 PD2.0 / PD3.0 快充协议", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小米澎湃 P3 充电芯片", + "value": "USB Type-C 双面充电接口", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小米澎湃 G2 自研电量计芯片", + "value": "标配 100W 超低功耗氮化镓充电器,兼容 PD 充电协议", + "highlight": false, + "source": "specs-page" + }, + { + "name": "100W 有线快充*", + "value": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|", + "highlight": false, + "source": "specs-page" + }, + { + "name": "22.5W 有线反充*", + "value": "触屏拍照|参考线", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "天玑 9500 旗舰芯", + "value": "全新对称双扬声器" + }, + { + "name": "AI 游戏独显 D2", + "value": "小米青山护眼 3.0" + }, + { + "name": "风冷主动散热", + "value": "f/1.68 超感光主摄" + }, + { + "name": "6000mm² 冰封冷泵", + "value": "小米澎湃 T1+ 信号增强芯" + }, + { + "name": "165Hz 超高刷电竞屏", + "value": "IP66|IP68|IP69" + }, + { + "name": "8550mAh 小米金沙江电池", + "value": "Xiaomi HyperOS 3" + } + ] + }, + { + "items": [ + { + "name": "内存:12GB/16GB", + "value": "长度:162.91mm" + }, + { + "name": "存储:256GB/512GB/1T", + "value": "宽度:77.93mm" + }, + { + "name": "LPDDR5X 内存", + "value": "厚度:8.18mm" + }, + { + "name": "UFS 4.1 高速存储", + "value": "重量:227g" + } + ] + }, + { + "items": [ + { + "name": "内置锂离子聚合物电池,不可拆卸", + "value": "手机支持 PD2.0 / PD3.0 快充协议" + }, + { + "name": "小米澎湃 P3 充电芯片", + "value": "USB Type-C 双面充电接口" + }, + { + "name": "小米澎湃 G2 自研电量计芯片", + "value": "标配 100W 超低功耗氮化镓充电器,兼容 PD 充电协议" + } + ] + }, + { + "items": [ + { + "name": "100W 有线快充*", + "value": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|" + }, + { + "name": "22.5W 有线反充*", + "value": "触屏拍照|参考线" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-k90-max/specs", + "buyPageParameterCount": 18, + "specsPageParameterCount": 15, + "specifications": { + "CPU型号": "天玑 9500", + "CPU主频": "4.21GHz", + "后置摄像头": "5000万像素+800万像素", + "前置摄像头": "2000万像素", + "屏幕": "AMOLED", + "屏幕尺寸": "6.83英寸", + "屏幕分辨率": "2772×1280", + "运行内存": "最高16GB", + "存储容量": "最高1024GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "超声波指纹", + "机身厚度": "8.18mm", + "电池容量": "8550mAh", + "有线快充": "100W", + "网络类型": "5G全网通", + "网络模式": "双卡双待", + "数据接口": "Type-C", + "天玑 9500 旗舰芯": "全新对称双扬声器", + "AI 游戏独显 D2": "小米青山护眼 3.0", + "风冷主动散热": "f/1.68 超感光主摄", + "6000mm² 冰封冷泵": "小米澎湃 T1+ 信号增强芯", + "165Hz 超高刷电竞屏": "IP66|IP68|IP69", + "8550mAh 小米金沙江电池": "Xiaomi HyperOS 3", + "内存:12GB/16GB": "长度:162.91mm", + "存储:256GB/512GB/1T": "宽度:77.93mm", + "LPDDR5X 内存": "厚度:8.18mm", + "UFS 4.1 高速存储": "重量:227g", + "内置锂离子聚合物电池,不可拆卸": "手机支持 PD2.0 / PD3.0 快充协议", + "小米澎湃 P3 充电芯片": "USB Type-C 双面充电接口", + "小米澎湃 G2 自研电量计芯片": "标配 100W 超低功耗氮化镓充电器,兼容 PD 充电协议", + "100W 有线快充*": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|", + "22.5W 有线反充*": "触屏拍照|参考线", + "版本": "12GB+256GB / 12GB+512GB / 16GB+256GB / 16GB+512GB / 16GB+1TB", + "颜色": "暗影黑 / 天际蓝 / 太空银", + "卖点": "天玑 9500 正代旗舰芯|游戏独显|超强风冷主动散热|165Hz 超高刷电竞屏", + "概述页": "https://www.mi.com/prod/redmi-k90-max", + "参数页": "https://www.mi.com/prod/redmi-k90-max/specs", + "来源": "小米商城 product_id=23969" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png", + "overview/053-overview.png", + "overview/054-overview.png", + "overview/055-overview.png", + "overview/056-overview.png", + "overview/057-overview.png", + "overview/058-overview.png", + "overview/059-overview.jpeg" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png", + "overview/053-overview.png", + "overview/054-overview.png", + "overview/055-overview.png", + "overview/056-overview.png", + "overview/057-overview.png", + "overview/058-overview.png", + "overview/059-overview.jpeg" + ], + "imageCount": 69, + "galleryCount": 9, + "overviewCount": 60, + "overviewImgPath": null + }, + { + "productId": 24895, + "sku": "SKU-PHONE-007", + "slug": "redmi-17c", + "name": "REDMI 17C", + "unitPrice": 799, + "description": "悦目大屏 一眼旗舰", + "brand": "REDMI", + "salesCount": 36895, + "parameterList": [ + { + "name": "CPU型号", + "value": "MediaTek Helio G81-Ultra", + "highlight": true + }, + { + "name": "CPU主频", + "value": "最高2.0GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "1300万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "500万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "LCD", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.88英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "1640*720", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高4GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高128GB", + "highlight": true + }, + { + "name": "NFC", + "value": "不支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "不支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "侧边指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.22mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "5160mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "18W", + "highlight": false + }, + { + "name": "网络类型", + "value": "4G", + "highlight": true + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type C", + "highlight": false + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-17c/specs", + "buyPageParameterCount": 18, + "specsPageParameterCount": 0, + "specifications": { + "CPU型号": "MediaTek Helio G81-Ultra", + "CPU主频": "最高2.0GHz", + "后置摄像头": "1300万像素", + "前置摄像头": "500万像素", + "屏幕": "LCD", + "屏幕尺寸": "6.88英寸", + "屏幕分辨率": "1640*720", + "运行内存": "最高4GB", + "存储容量": "最高128GB", + "NFC": "不支持", + "红外遥控": "不支持", + "指纹识别": "侧边指纹", + "机身厚度": "8.22mm", + "电池容量": "5160mAh", + "有线快充": "18W", + "网络类型": "4G", + "网络模式": "双卡双待", + "数据接口": "Type C", + "版本": "4GB+128GB / 4GB+64GB", + "颜色": "丹霞红 / 黑色 / 海风蓝", + "卖点": "悦目大屏 一眼旗舰", + "概述页": "https://www.mi.com/prod/redmi-17c", + "来源": "小米商城 product_id=24895" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png" + ], + "imageCount": 41, + "galleryCount": 9, + "overviewCount": 32, + "overviewImgPath": null + }, + { + "productId": 24814, + "sku": "SKU-PHONE-008", + "slug": "xiaomi-17t", + "name": "Xiaomi 17T", + "unitPrice": 2999, + "description": "6.59\"低功耗超级阳光屏 |徕卡 5X 浮动潜望长焦|徕卡 Live 动态照片", + "brand": "Xiaomi", + "salesCount": 36814, + "parameterList": [ + { + "name": "CPU型号", + "value": "天玑8500 Ultra", + "highlight": true + }, + { + "name": "CPU主频", + "value": "3.4GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素+5000万像素+1200万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "3200万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "OLED", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.59英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2756x1268", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高12GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高512GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "屏下指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.17mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "7000mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "67W", + "highlight": false + }, + { + "name": "网络类型", + "value": "5G全网通", + "highlight": true + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "支持频段", + "value": "5G: n1 / n3 / n5 / n8 / n28a / n34 / n38 / n39 / n40 / n41 / n48 / n77 / n78", + "highlight": false, + "source": "specs-page" + }, + { + "name": "前置相机支持", + "value": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|触屏拍照|参考线", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "支持频段", + "value": "5G: n1 / n3 / n5 / n8 / n28a / n34 / n38 / n39 / n40 / n41 / n48 / n77 / n78" + } + ] + }, + { + "items": [ + { + "name": "前置相机支持", + "value": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|触屏拍照|参考线" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-17t/specs", + "buyPageParameterCount": 18, + "specsPageParameterCount": 2, + "specifications": { + "CPU型号": "天玑8500 Ultra", + "CPU主频": "3.4GHz", + "后置摄像头": "5000万像素+5000万像素+1200万像素", + "前置摄像头": "3200万像素", + "屏幕": "OLED", + "屏幕尺寸": "6.59英寸", + "屏幕分辨率": "2756x1268", + "运行内存": "最高12GB", + "存储容量": "最高512GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "屏下指纹", + "机身厚度": "8.17mm", + "电池容量": "7000mAh", + "有线快充": "67W", + "网络类型": "5G全网通", + "网络模式": "双卡双待", + "数据接口": "Type-C", + "支持频段": "5G: n1 / n3 / n5 / n8 / n28a / n34 / n38 / n39 / n40 / n41 / n48 / n77 / n78", + "前置相机支持": "动态照片|人像虚化|照片水印|视频美颜|延时摄影|慢动作|视频提词器|倒计时拍照|手势拍照|声控拍照|触屏拍照|参考线", + "版本": "12GB+256GB / 12GB+512GB", + "颜色": "黑色 / 幻彩白 / 冰莓紫", + "卖点": "6.59\"低功耗超级阳光屏 |徕卡 5X 浮动潜望长焦|徕卡 Live 动态照片", + "概述页": "https://www.mi.com/prod/xiaomi-17t", + "参数页": "https://www.mi.com/prod/xiaomi-17t/specs", + "来源": "小米商城 product_id=24814" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png" + ], + "imageCount": 57, + "galleryCount": 9, + "overviewCount": 48, + "overviewImgPath": null + }, + { + "productId": 24809, + "sku": "SKU-PHONE-010", + "slug": "xiaomi-17t-pro", + "name": "Xiaomi 17T Pro", + "unitPrice": 3999, + "description": "天玑 9500 旗舰性能芯 | 徕卡光学 Summilux 高速镜头 | 6.8英寸低功耗超级阳光屏", + "brand": "Xiaomi", + "salesCount": 36809, + "parameterList": [ + { + "name": "CPU型号", + "value": "天玑9500", + "highlight": true + }, + { + "name": "CPU主频", + "value": "4.21GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "5000万像素+5000万像素+1200万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "3200万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "OLED", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.83英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "2772x1280", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高16GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高512GB", + "highlight": true + }, + { + "name": "NFC", + "value": "支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "屏下", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.25mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "7000mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "100W", + "highlight": false + }, + { + "name": "无线快充", + "value": "50W", + "highlight": false + }, + { + "name": "网络类型", + "value": "5G全网通", + "highlight": true + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + }, + { + "name": "支持频段", + "value": "5G: n1 / n2 / n3 / n5 / n7 / n8 / n12 / n20 / n25 / n26 / n28 / n34 / n38 / n39 / n40 / n41 / n48 / n66 / n71 / n75 / n77 / n78", + "highlight": false, + "source": "specs-page" + }, + { + "name": "镀膜|1G+6P 非球面高透镜组", + "value": "/3.0 光圈|等效 115mm 焦距|30cm~无限远 对焦距离|支持 OIS 光学防抖", + "highlight": false, + "source": "specs-page" + }, + { + "name": "5000万 徕卡 5X 潜望长焦:", + "value": "1200万 徕卡超广角:", + "highlight": false, + "source": "specs-page" + }, + { + "name": "前置相机支持", + "value": "动态照片丨人像虚化丨照片水印丨视频美颜丨延时摄影丨慢动作丨视频提词器丨倒计时拍照丨手势拍照丨声控拍照丨触屏拍照丨参考线", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "支持频段", + "value": "5G: n1 / n2 / n3 / n5 / n7 / n8 / n12 / n20 / n25 / n26 / n28 / n34 / n38 / n39 / n40 / n41 / n48 / n66 / n71 / n75 / n77 / n78" + } + ] + }, + { + "items": [ + { + "name": "镀膜|1G+6P 非球面高透镜组", + "value": "/3.0 光圈|等效 115mm 焦距|30cm~无限远 对焦距离|支持 OIS 光学防抖" + }, + { + "name": "5000万 徕卡 5X 潜望长焦:", + "value": "1200万 徕卡超广角:" + } + ] + }, + { + "items": [ + { + "name": "前置相机支持", + "value": "动态照片丨人像虚化丨照片水印丨视频美颜丨延时摄影丨慢动作丨视频提词器丨倒计时拍照丨手势拍照丨声控拍照丨触屏拍照丨参考线" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-17t-pro/specs", + "buyPageParameterCount": 19, + "specsPageParameterCount": 4, + "specifications": { + "CPU型号": "天玑9500", + "CPU主频": "4.21GHz", + "后置摄像头": "5000万像素+5000万像素+1200万像素", + "前置摄像头": "3200万像素", + "屏幕": "OLED", + "屏幕尺寸": "6.83英寸", + "屏幕分辨率": "2772x1280", + "运行内存": "最高16GB", + "存储容量": "最高512GB", + "NFC": "支持", + "红外遥控": "支持", + "指纹识别": "屏下", + "机身厚度": "8.25mm", + "电池容量": "7000mAh", + "有线快充": "100W", + "无线快充": "50W", + "网络类型": "5G全网通", + "网络模式": "双卡双待", + "数据接口": "Type-C", + "支持频段": "5G: n1 / n2 / n3 / n5 / n7 / n8 / n12 / n20 / n25 / n26 / n28 / n34 / n38 / n39 / n40 / n41 / n48 / n66 / n71 / n75 / n77 / n78", + "镀膜|1G+6P 非球面高透镜组": "/3.0 光圈|等效 115mm 焦距|30cm~无限远 对焦距离|支持 OIS 光学防抖", + "5000万 徕卡 5X 潜望长焦:": "1200万 徕卡超广角:", + "前置相机支持": "动态照片丨人像虚化丨照片水印丨视频美颜丨延时摄影丨慢动作丨视频提词器丨倒计时拍照丨手势拍照丨声控拍照丨触屏拍照丨参考线", + "版本": "12GB+256GB / 12GB+512GB / 16GB+512GB", + "颜色": "黑色 / 午夜蓝 / 星云紫", + "卖点": "天玑 9500 旗舰性能芯 | 徕卡光学 Summilux 高速镜头 | 6.8英寸低功耗超级阳光屏", + "概述页": "https://www.mi.com/prod/xiaomi-17t-pro", + "参数页": "https://www.mi.com/prod/xiaomi-17t-pro/specs", + "来源": "小米商城 product_id=24809" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png", + "overview/052-overview.png" + ], + "imageCount": 62, + "galleryCount": 9, + "overviewCount": 53, + "overviewImgPath": null + }, + { + "productId": 24884, + "sku": "SKU-PHONE-011", + "slug": "redmi-17-5g", + "name": "REDMI 17 5G", + "unitPrice": 999, + "description": "大屏长续航 久用也流畅", + "brand": "REDMI", + "salesCount": 36884, + "parameterList": [ + { + "name": "CPU型号", + "value": "紫光展锐 T8300", + "highlight": true + }, + { + "name": "CPU主频", + "value": "最高2.2GHz", + "highlight": false + }, + { + "name": "后置摄像头", + "value": "1300万像素", + "highlight": true + }, + { + "name": "前置摄像头", + "value": "800万像素", + "highlight": false + }, + { + "name": "屏幕", + "value": "LCD", + "highlight": false + }, + { + "name": "屏幕尺寸", + "value": "6.9英寸", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "1600*720", + "highlight": true + }, + { + "name": "运行内存", + "value": "最高6GB", + "highlight": true + }, + { + "name": "存储容量", + "value": "最高128GB", + "highlight": true + }, + { + "name": "NFC", + "value": "不支持", + "highlight": false + }, + { + "name": "红外遥控", + "value": "不支持", + "highlight": false + }, + { + "name": "指纹识别", + "value": "侧边指纹", + "highlight": false + }, + { + "name": "机身厚度", + "value": "8.15mm", + "highlight": true + }, + { + "name": "电池容量", + "value": "6300mAh", + "highlight": true + }, + { + "name": "有线快充", + "value": "15W", + "highlight": false + }, + { + "name": "网络类型", + "value": "5G全网通", + "highlight": true + }, + { + "name": "网络模式", + "value": "双卡双待", + "highlight": true + }, + { + "name": "数据接口", + "value": "Type-C", + "highlight": false + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-17-5g/specs", + "buyPageParameterCount": 18, + "specsPageParameterCount": 0, + "specifications": { + "CPU型号": "紫光展锐 T8300", + "CPU主频": "最高2.2GHz", + "后置摄像头": "1300万像素", + "前置摄像头": "800万像素", + "屏幕": "LCD", + "屏幕尺寸": "6.9英寸", + "屏幕分辨率": "1600*720", + "运行内存": "最高6GB", + "存储容量": "最高128GB", + "NFC": "不支持", + "红外遥控": "不支持", + "指纹识别": "侧边指纹", + "机身厚度": "8.15mm", + "电池容量": "6300mAh", + "有线快充": "15W", + "网络类型": "5G全网通", + "网络模式": "双卡双待", + "数据接口": "Type-C", + "版本": "4GB+128GB / 6GB+128GB", + "颜色": "羽雾白 / 星岩黑 / 冷雾蓝", + "卖点": "大屏长续航 久用也流畅", + "概述页": "https://www.mi.com/prod/redmi-17-5g", + "来源": "小米商城 product_id=24884" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-gallery.png", + "gallery/02-gallery.png", + "gallery/03-gallery.png", + "gallery/04-cover.png", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png" + ], + "imageCount": 51, + "galleryCount": 9, + "overviewCount": 42, + "overviewImgPath": null + }, + { + "productId": 10050173, + "sku": "SKU-TV-001", + "slug": "xiaomi-tv-s-miniled-2026", + "name": "小米电视S Mini LED 2026 系列", + "unitPrice": 3199, + "description": "小米电视首发低反屏,超薄一体化机身,Mini LED 画质新旗舰", + "brand": "Xiaomi", + "salesCount": 30173, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "55英寸", + "highlight": true + }, + { + "name": "系列", + "value": "小米电视S Mini LED 2026 至尊版", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "3840*2160", + "highlight": true + }, + { + "name": "遥控方式", + "value": "NFC蓝牙语音遥控器", + "highlight": true + }, + { + "name": "刷新率", + "value": "288Hz", + "highlight": true + }, + { + "name": "CPU", + "value": "Cortex-A73×4", + "highlight": true + }, + { + "name": "内存", + "value": "4GB", + "highlight": true + }, + { + "name": "闪存", + "value": "64GB", + "highlight": true + }, + { + "name": "能效等级", + "value": "1级能效", + "highlight": true + }, + { + "name": "整机功率", + "value": "230W", + "highlight": true + }, + { + "name": "发布时间", + "value": "2026年4月", + "highlight": true + }, + { + "name": "背光方式", + "value": "Mini LED", + "highlight": true + }, + { + "name": "色域", + "value": "92%/94% DCI-P3", + "highlight": true + }, + { + "name": "运动补偿", + "value": "4K120Hz", + "highlight": true + }, + { + "name": "扬声器功率", + "value": "2*10+25+2*10W", + "highlight": true + }, + { + "name": "USB", + "value": "USB 2.0×2个", + "highlight": true + }, + { + "name": "蓝牙", + "value": "支持", + "highlight": true + }, + { + "name": "WIFI", + "value": "WiFi 6", + "highlight": true + }, + { + "name": "含底座尺寸(宽*高*厚)mm", + "value": "1225*777*262", + "highlight": true + }, + { + "name": "外包装尺寸(宽*高*厚)mm", + "value": "1400*830*134", + "highlight": true + }, + { + "name": "裸机含底座重量", + "value": "13Kg", + "highlight": true + }, + { + "name": "扬声器数量", + "value": "5个", + "highlight": true + }, + { + "name": "推荐观看距离", + "value": "1.9-2.3m", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-tv-s-miniled-2026/specs", + "buyPageParameterCount": 23, + "specsPageParameterCount": 0, + "specifications": { + "屏幕尺寸": "55英寸", + "系列": "小米电视S Mini LED 2026 至尊版", + "屏幕分辨率": "3840*2160", + "遥控方式": "NFC蓝牙语音遥控器", + "刷新率": "288Hz", + "CPU": "Cortex-A73×4", + "内存": "4GB", + "闪存": "64GB", + "能效等级": "1级能效", + "整机功率": "230W", + "发布时间": "2026年4月", + "背光方式": "Mini LED", + "色域": "92%/94% DCI-P3", + "运动补偿": "4K120Hz", + "扬声器功率": "2*10+25+2*10W", + "USB": "USB 2.0×2个", + "蓝牙": "支持", + "WIFI": "WiFi 6", + "含底座尺寸(宽*高*厚)mm": "1225*777*262", + "外包装尺寸(宽*高*厚)mm": "1400*830*134", + "裸机含底座重量": "13Kg", + "扬声器数量": "5个", + "推荐观看距离": "1.9-2.3m", + "类型": "2026至尊版 / 2026版", + "尺寸": "55英寸 / 65英寸 / 75英寸 / 85英寸 / 100英寸", + "卖点": "小米电视首发低反屏,超薄一体化机身,Mini LED 画质新旗舰", + "概述页": "https://www.mi.com/prod/xiaomi-tv-s-miniled-2026", + "来源": "小米商城 product_id=10050173" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.jpg", + "gallery/02-detail.jpg", + "gallery/03-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.jpg", + "gallery/02-detail.jpg", + "gallery/03-detail.png" + ], + "imageCount": 4, + "galleryCount": 4, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 10050135, + "sku": "SKU-TV-002", + "slug": "redmi-tv-x-2026", + "name": "REDMI 电视 X 2026", + "unitPrice": 2499, + "description": "Mini LED电视,普及新风暴,画质革命,全面升级,REDMI电视首次采用高端先进Mini LED显示技术", + "brand": "REDMI", + "salesCount": 30135, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "55英寸", + "highlight": true + }, + { + "name": "系列", + "value": "REDMI 电视 X 2026", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "3840*2160", + "highlight": true + }, + { + "name": "遥控方式", + "value": "NFC蓝牙语音遥控器", + "highlight": true + }, + { + "name": "刷新率", + "value": "288Hz", + "highlight": true + }, + { + "name": "CPU", + "value": "Cortex-A73×4", + "highlight": true + }, + { + "name": "内存", + "value": "4GB", + "highlight": true + }, + { + "name": "闪存", + "value": "64GB", + "highlight": true + }, + { + "name": "能效等级", + "value": "1级能效", + "highlight": true + }, + { + "name": "整机功率", + "value": "230W", + "highlight": true + }, + { + "name": "发布时间", + "value": "2025年11月", + "highlight": true + }, + { + "name": "背光方式", + "value": "Mini LED", + "highlight": true + }, + { + "name": "色域", + "value": "DCI-P3 94%", + "highlight": true + }, + { + "name": "运动补偿", + "value": "4K120Hz", + "highlight": true + }, + { + "name": "扬声器功率", + "value": "2*15W", + "highlight": true + }, + { + "name": "USB", + "value": "USB 2.0×1,USB 3.0×1个", + "highlight": true + }, + { + "name": "蓝牙", + "value": "支持", + "highlight": true + }, + { + "name": "WIFI", + "value": "WiFi 6", + "highlight": true + }, + { + "name": "含底座尺寸(宽*高*厚)mm", + "value": "1226*767*280", + "highlight": true + }, + { + "name": "外包装尺寸(宽*高*厚)mm", + "value": "1400*830*134", + "highlight": true + }, + { + "name": "裸机含底座重量", + "value": "11.7Kg", + "highlight": true + }, + { + "name": "扬声器数量", + "value": "4个", + "highlight": true + }, + { + "name": "推荐观看距离", + "value": "1.9-2.3m", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-tv-x-2026/specs", + "buyPageParameterCount": 23, + "specsPageParameterCount": 0, + "specifications": { + "屏幕尺寸": "55英寸", + "系列": "REDMI 电视 X 2026", + "屏幕分辨率": "3840*2160", + "遥控方式": "NFC蓝牙语音遥控器", + "刷新率": "288Hz", + "CPU": "Cortex-A73×4", + "内存": "4GB", + "闪存": "64GB", + "能效等级": "1级能效", + "整机功率": "230W", + "发布时间": "2025年11月", + "背光方式": "Mini LED", + "色域": "DCI-P3 94%", + "运动补偿": "4K120Hz", + "扬声器功率": "2*15W", + "USB": "USB 2.0×1,USB 3.0×1个", + "蓝牙": "支持", + "WIFI": "WiFi 6", + "含底座尺寸(宽*高*厚)mm": "1226*767*280", + "外包装尺寸(宽*高*厚)mm": "1400*830*134", + "裸机含底座重量": "11.7Kg", + "扬声器数量": "4个", + "推荐观看距离": "1.9-2.3m", + "属性": "2026款", + "尺寸": "55英寸 / 65英寸 / 75英寸 / 85英寸 / 98英寸", + "卖点": "Mini LED电视,普及新风暴,画质革命,全面升级,REDMI电视首次采用高端先进Mini LED显示技术", + "概述页": "https://www.mi.com/prod/redmi-tv-x-2026", + "来源": "小米商城 product_id=10050135" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 10050074, + "sku": "SKU-TV-003", + "slug": "redmi-tv-a-pro", + "name": "REDMI智能电视 A Pro系列", + "unitPrice": 1349, + "description": "4K超高清|94%DCI-P3高色域|3+64GB大内存", + "brand": "REDMI", + "salesCount": 30074, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "43英寸", + "highlight": true + }, + { + "name": "系列", + "value": "REDMI 电视 A Pro 2026", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "3840*2160", + "highlight": true + }, + { + "name": "遥控方式", + "value": "NFC塑胶遥控器", + "highlight": true + }, + { + "name": "刷新率", + "value": "60Hz", + "highlight": true + }, + { + "name": "内存", + "value": "3GB", + "highlight": true + }, + { + "name": "闪存", + "value": "64GB", + "highlight": true + }, + { + "name": "能效等级", + "value": "1级能效", + "highlight": true + }, + { + "name": "整机功率", + "value": "75W", + "highlight": true + }, + { + "name": "发布时间", + "value": "2026年3月", + "highlight": true + }, + { + "name": "背光方式", + "value": "DLED", + "highlight": true + }, + { + "name": "色域", + "value": "76%/78% DCI-P3", + "highlight": true + }, + { + "name": "运动补偿", + "value": "支持", + "highlight": true + }, + { + "name": "扬声器功率", + "value": "2*8W", + "highlight": true + }, + { + "name": "USB", + "value": "USB 2.0×1,USB 3.0×1个", + "highlight": true + }, + { + "name": "蓝牙", + "value": "支持", + "highlight": true + }, + { + "name": "WIFI", + "value": "WiFi 6", + "highlight": true + }, + { + "name": "是否为全面屏", + "value": "是", + "highlight": true + }, + { + "name": "含底座尺寸(宽*高*厚)mm", + "value": "957*600*209", + "highlight": true + }, + { + "name": "外包装尺寸(宽*高*厚)mm", + "value": "1076*645*114", + "highlight": true + }, + { + "name": "裸机含底座重量", + "value": "5.6Kg", + "highlight": true + }, + { + "name": "扬声器数量", + "value": "2个", + "highlight": true + }, + { + "name": "推荐观看距离", + "value": "1.9m以内", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-tv-a-pro/specs", + "buyPageParameterCount": 23, + "specsPageParameterCount": 0, + "specifications": { + "屏幕尺寸": "43英寸", + "系列": "REDMI 电视 A Pro 2026", + "屏幕分辨率": "3840*2160", + "遥控方式": "NFC塑胶遥控器", + "刷新率": "60Hz", + "内存": "3GB", + "闪存": "64GB", + "能效等级": "1级能效", + "整机功率": "75W", + "发布时间": "2026年3月", + "背光方式": "DLED", + "色域": "76%/78% DCI-P3", + "运动补偿": "支持", + "扬声器功率": "2*8W", + "USB": "USB 2.0×1,USB 3.0×1个", + "蓝牙": "支持", + "WIFI": "WiFi 6", + "是否为全面屏": "是", + "含底座尺寸(宽*高*厚)mm": "957*600*209", + "外包装尺寸(宽*高*厚)mm": "1076*645*114", + "裸机含底座重量": "5.6Kg", + "扬声器数量": "2个", + "推荐观看距离": "1.9m以内", + "尺寸": "43英寸 / 50英寸 / 55英寸 / 65英寸 / 70英寸 / 75英寸", + "属性": "2026款 / 2025款 / 一级能效版", + "卖点": "4K超高清|94%DCI-P3高色域|3+64GB大内存", + "概述页": "https://www.mi.com/prod/redmi-tv-a-pro", + "来源": "小米商城 product_id=10050074" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.jpg", + "gallery/02-detail.jpg", + "gallery/03-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.jpg", + "gallery/02-detail.jpg", + "gallery/03-detail.png" + ], + "imageCount": 4, + "galleryCount": 4, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 10050131, + "sku": "SKU-TV-004", + "slug": "xiaomi-tv-s-pro-miniled-2026", + "name": "小米电视S Pro Mini LED 2026", + "unitPrice": 5499, + "description": "首发搭载小米澎湃OS 3,画质外观新突破", + "brand": "Xiaomi", + "salesCount": 30131, + "parameterList": [ + { + "name": "屏幕尺寸", + "value": "65英寸", + "highlight": true + }, + { + "name": "系列", + "value": "S Pro Mini LED 2026", + "highlight": true + }, + { + "name": "屏幕分辨率", + "value": "3840*2160", + "highlight": true + }, + { + "name": "遥控方式", + "value": "蓝牙遥控器 × 1", + "highlight": true + }, + { + "name": "刷新率", + "value": "330Hz", + "highlight": true + }, + { + "name": "CPU", + "value": "Cortex-A73×4", + "highlight": true + }, + { + "name": "内存", + "value": "4GB", + "highlight": true + }, + { + "name": "闪存", + "value": "64GB", + "highlight": true + }, + { + "name": "能效等级", + "value": "1级能效", + "highlight": true + }, + { + "name": "整机功率", + "value": "350W", + "highlight": true + }, + { + "name": "发布时间", + "value": "2025年9月", + "highlight": true + }, + { + "name": "背光方式", + "value": "Mini LED", + "highlight": true + }, + { + "name": "色域", + "value": "92%/95% DCI-P3", + "highlight": true + }, + { + "name": "运动补偿", + "value": "4K120Hz", + "highlight": true + }, + { + "name": "扬声器功率", + "value": "2*10+25+2*8W", + "highlight": true + }, + { + "name": "USB", + "value": "USB 2.0×1,USB 3.0×1个", + "highlight": true + }, + { + "name": "蓝牙", + "value": "支持", + "highlight": true + }, + { + "name": "WIFI", + "value": "WiFi 6", + "highlight": true + }, + { + "name": "含底座尺寸(宽*高*厚)mm", + "value": "1437*896*299", + "highlight": true + }, + { + "name": "外包装尺寸(宽*高*厚)mm", + "value": "1630*983*160", + "highlight": true + }, + { + "name": "裸机含底座重量", + "value": "22.2Kg", + "highlight": true + }, + { + "name": "扬声器数量", + "value": "5个", + "highlight": true + }, + { + "name": "推荐观看距离", + "value": "2.3-2.5m", + "highlight": true + } + ], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-tv-s-pro-miniled-2026/specs", + "buyPageParameterCount": 23, + "specsPageParameterCount": 0, + "specifications": { + "屏幕尺寸": "65英寸", + "系列": "S Pro Mini LED 2026", + "屏幕分辨率": "3840*2160", + "遥控方式": "蓝牙遥控器 × 1", + "刷新率": "330Hz", + "CPU": "Cortex-A73×4", + "内存": "4GB", + "闪存": "64GB", + "能效等级": "1级能效", + "整机功率": "350W", + "发布时间": "2025年9月", + "背光方式": "Mini LED", + "色域": "92%/95% DCI-P3", + "运动补偿": "4K120Hz", + "扬声器功率": "2*10+25+2*8W", + "USB": "USB 2.0×1,USB 3.0×1个", + "蓝牙": "支持", + "WIFI": "WiFi 6", + "含底座尺寸(宽*高*厚)mm": "1437*896*299", + "外包装尺寸(宽*高*厚)mm": "1630*983*160", + "裸机含底座重量": "22.2Kg", + "扬声器数量": "5个", + "推荐观看距离": "2.3-2.5m", + "颜色": "黑色", + "尺寸": "65英寸 / 75英寸 / 85英寸 / 98英寸", + "卖点": "首发搭载小米澎湃OS 3,画质外观新突破", + "概述页": "https://www.mi.com/prod/xiaomi-tv-s-pro-miniled-2026", + "来源": "小米商城 product_id=10050131" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.jpg", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png" + ], + "imageCount": 8, + "galleryCount": 8, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 24629, + "sku": "SKU-WEAR-001", + "slug": "xiaomi-band-10-pro", + "name": "小米手环10 Pro", + "unitPrice": 399, + "description": "「国补限时立减15%,到手价339.15元起!」 轻薄铝合金机身 | 专业运动健康管理 | 21天超长续航", + "brand": "Xiaomi", + "salesCount": 36629, + "parameterList": [], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-band-10-pro/specs", + "buyPageParameterCount": 0, + "specsPageParameterCount": 0, + "specifications": { + "颜色": "黑色 / 银色 / 樱花粉 / 珊瑚橙 / 乳酪白 / 陶瓷版 陶瓷白", + "通用": "通用", + "卖点": "「国补限时立减15%,到手价339.15元起!」 轻薄铝合金机身 | 专业运动健康管理 | 21天超长续航", + "概述页": "https://www.mi.com/prod/xiaomi-band-10-pro", + "来源": "小米商城 product_id=24629" + }, + "galleryFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.jpg", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 21404, + "sku": "SKU-WEAR-002", + "slug": "xiaomi-band-10", + "name": "小米手环10", + "unitPrice": 289, + "description": "【国补至高立省15%】 2.0mm超窄四等边,满屏高光|睡眠健康管理|全天心率监测", + "brand": "Xiaomi", + "salesCount": 33404, + "parameterList": [], + "specsPageGroups": [], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-band-10/specs", + "buyPageParameterCount": 0, + "specsPageParameterCount": 0, + "specifications": { + "颜色": "标准版 黑色 / 标准版 银色 / 标准版 嫩芽绿 / 标准版 粉金", + "通用": "标准版", + "卖点": "【国补至高立省15%】 2.0mm超窄四等边,满屏高光|睡眠健康管理|全天心率监测", + "概述页": "https://www.mi.com/prod/xiaomi-band-10", + "来源": "小米商城 product_id=21404" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "imageCount": 9, + "galleryCount": 9, + "overviewCount": 0, + "overviewImgPath": null + }, + { + "productId": 23408, + "sku": "SKU-WEAR-003", + "slug": "xiaomi-watch-s5", + "name": "Xiaomi Watch S5", + "unitPrice": 1099, + "description": "限时闪降100元,至高享3期免息,以旧换新至高补贴135元 21天超长续航 | 小米澎湃OS 3 | 全新骑行体验", + "brand": "Xiaomi", + "salesCount": 35408, + "parameterList": [ + { + "name": "屏幕分辨率", + "value": "815mAh", + "highlight": false, + "source": "specs-page" + }, + { + "name": "屏幕亮度", + "value": "锂离子聚合物电池", + "highlight": false, + "source": "specs-page" + }, + { + "name": "工作温度", + "value": "简体中文、英文、繁体中文", + "highlight": false, + "source": "specs-page" + }, + { + "name": "防水等级", + "value": "Android 8.0 或 iOS 14.0 及以上版本", + "highlight": false, + "source": "specs-page" + }, + { + "name": "传感器", + "value": "北斗、GPS、GLONASS、Galileo、QZSS", + "highlight": false, + "source": "specs-page" + }, + { + "name": "充电接口", + "value": "双模蓝牙 BLE/BT 5.4", + "highlight": false, + "source": "specs-page" + }, + { + "name": "按钮", + "value": "支持,2.4GHz", + "highlight": false, + "source": "specs-page" + }, + { + "name": "麦克风", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "扬声器", + "value": "支持(仅 eSIM 版)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "兼容 Android 手机", + "value": "兼容 iOS 手机", + "highlight": false, + "source": "specs-page" + }, + { + "name": "Android 8.0 及以上版本", + "value": "iOS 14.0 及以上版本", + "highlight": false, + "source": "specs-page" + }, + { + "name": "蓝牙至长使用场景", + "value": "21 天", + "highlight": false, + "source": "specs-page" + }, + { + "name": "蓝牙常规监测场景", + "value": "14 天", + "highlight": false, + "source": "specs-page" + }, + { + "name": "蓝牙 AOD 常规监测场景", + "value": "9 天", + "highlight": false, + "source": "specs-page" + }, + { + "name": "eSIM 至长使用场景", + "value": "14 天", + "highlight": false, + "source": "specs-page" + }, + { + "name": "eSIM 常规监测场景", + "value": "8 天", + "highlight": false, + "source": "specs-page" + }, + { + "name": "卡路里", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "心率监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "睡眠监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "压力监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "呼吸放松", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "血氧监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "睡眠呼吸健康风险研究", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "心脏健康研究", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "无感血压健康研究", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "睡眠健康研究", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "健康扫描", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "跌倒检测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "女性健康", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "心率过高/过低提醒", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "门锁联动", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "远程查找手表", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "疲劳驾驶提醒", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "导航振动辅助", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小憩模式联动", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "汽车超级任务", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小米汽车APP", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "支付宝扫码支付", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "微信扫码支付", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC 公交卡", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC 模拟门卡", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC 空白门卡", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC 车钥匙", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "消息提醒", + "value": "独立通信(仅eSIM版手表支持)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "支持", + "value": "支持(需连接Wi-Fi或eSIM网络)", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小组件", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "勿扰模式同步", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "息屏显示", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "设备密码", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "双手模式", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "双键截屏", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "消息隐私模式", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "超级岛(焦点信息提醒)", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "融合设备中心", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "快捷手势(自定义手势)", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "快捷手势(控制其他设备)", + "value": "部分支持", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "屏幕分辨率", + "value": "815mAh" + }, + { + "name": "屏幕亮度", + "value": "锂离子聚合物电池" + } + ] + }, + { + "items": [ + { + "name": "工作温度", + "value": "简体中文、英文、繁体中文" + }, + { + "name": "防水等级", + "value": "Android 8.0 或 iOS 14.0 及以上版本" + }, + { + "name": "传感器", + "value": "北斗、GPS、GLONASS、Galileo、QZSS" + }, + { + "name": "充电接口", + "value": "双模蓝牙 BLE/BT 5.4" + }, + { + "name": "按钮", + "value": "支持,2.4GHz" + }, + { + "name": "麦克风", + "value": "支持" + }, + { + "name": "扬声器", + "value": "支持(仅 eSIM 版)" + } + ] + }, + { + "items": [ + { + "name": "兼容 Android 手机", + "value": "兼容 iOS 手机" + }, + { + "name": "Android 8.0 及以上版本", + "value": "iOS 14.0 及以上版本" + } + ] + }, + { + "items": [ + { + "name": "蓝牙至长使用场景", + "value": "21 天" + }, + { + "name": "蓝牙常规监测场景", + "value": "14 天" + }, + { + "name": "蓝牙 AOD 常规监测场景", + "value": "9 天" + }, + { + "name": "eSIM 至长使用场景", + "value": "14 天" + }, + { + "name": "eSIM 常规监测场景", + "value": "8 天" + } + ] + }, + { + "items": [ + { + "name": "卡路里", + "value": "支持" + }, + { + "name": "心率监测", + "value": "支持" + }, + { + "name": "睡眠监测", + "value": "支持" + }, + { + "name": "压力监测", + "value": "支持" + }, + { + "name": "呼吸放松", + "value": "支持" + }, + { + "name": "血氧监测", + "value": "支持" + }, + { + "name": "睡眠呼吸健康风险研究", + "value": "支持" + }, + { + "name": "心脏健康研究", + "value": "支持" + }, + { + "name": "无感血压健康研究", + "value": "支持" + }, + { + "name": "睡眠健康研究", + "value": "支持" + }, + { + "name": "健康扫描", + "value": "支持" + }, + { + "name": "跌倒检测", + "value": "支持" + }, + { + "name": "女性健康", + "value": "支持" + }, + { + "name": "心率过高/过低提醒", + "value": "支持" + } + ] + }, + { + "items": [ + { + "name": "门锁联动", + "value": "部分支持" + }, + { + "name": "远程查找手表", + "value": "部分支持" + }, + { + "name": "疲劳驾驶提醒", + "value": "部分支持" + }, + { + "name": "导航振动辅助", + "value": "部分支持" + }, + { + "name": "小憩模式联动", + "value": "部分支持" + }, + { + "name": "汽车超级任务", + "value": "部分支持" + }, + { + "name": "小米汽车APP", + "value": "支持" + } + ] + }, + { + "items": [ + { + "name": "支付宝扫码支付", + "value": "支持" + }, + { + "name": "微信扫码支付", + "value": "支持" + }, + { + "name": "NFC 公交卡", + "value": "支持" + }, + { + "name": "NFC 模拟门卡", + "value": "支持" + }, + { + "name": "NFC 空白门卡", + "value": "支持" + }, + { + "name": "NFC 车钥匙", + "value": "支持" + } + ] + }, + { + "items": [ + { + "name": "消息提醒", + "value": "独立通信(仅eSIM版手表支持)" + } + ] + }, + { + "items": [ + { + "name": "支持", + "value": "支持(需连接Wi-Fi或eSIM网络)" + } + ] + }, + { + "items": [ + { + "name": "小组件", + "value": "支持" + }, + { + "name": "勿扰模式同步", + "value": "部分支持" + }, + { + "name": "息屏显示", + "value": "支持" + }, + { + "name": "设备密码", + "value": "支持" + }, + { + "name": "双手模式", + "value": "支持" + }, + { + "name": "双键截屏", + "value": "支持" + }, + { + "name": "消息隐私模式", + "value": "支持" + }, + { + "name": "超级岛(焦点信息提醒)", + "value": "部分支持" + }, + { + "name": "融合设备中心", + "value": "部分支持" + }, + { + "name": "快捷手势(自定义手势)", + "value": "部分支持" + }, + { + "name": "快捷手势(控制其他设备)", + "value": "部分支持" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/xiaomi-watch-s5/specs", + "buyPageParameterCount": 0, + "specsPageParameterCount": 56, + "specifications": { + "屏幕分辨率": "815mAh", + "屏幕亮度": "锂离子聚合物电池", + "工作温度": "简体中文、英文、繁体中文", + "防水等级": "Android 8.0 或 iOS 14.0 及以上版本", + "传感器": "北斗、GPS、GLONASS、Galileo、QZSS", + "充电接口": "双模蓝牙 BLE/BT 5.4", + "按钮": "支持,2.4GHz", + "麦克风": "支持", + "扬声器": "支持(仅 eSIM 版)", + "兼容 Android 手机": "兼容 iOS 手机", + "Android 8.0 及以上版本": "iOS 14.0 及以上版本", + "蓝牙至长使用场景": "21 天", + "蓝牙常规监测场景": "14 天", + "蓝牙 AOD 常规监测场景": "9 天", + "eSIM 至长使用场景": "14 天", + "eSIM 常规监测场景": "8 天", + "卡路里": "支持", + "心率监测": "支持", + "睡眠监测": "支持", + "压力监测": "支持", + "呼吸放松": "支持", + "血氧监测": "支持", + "睡眠呼吸健康风险研究": "支持", + "心脏健康研究": "支持", + "无感血压健康研究": "支持", + "睡眠健康研究": "支持", + "健康扫描": "支持", + "跌倒检测": "支持", + "女性健康": "支持", + "心率过高/过低提醒": "支持", + "门锁联动": "部分支持", + "远程查找手表": "部分支持", + "疲劳驾驶提醒": "部分支持", + "导航振动辅助": "部分支持", + "小憩模式联动": "部分支持", + "汽车超级任务": "部分支持", + "小米汽车APP": "支持", + "支付宝扫码支付": "支持", + "微信扫码支付": "支持", + "NFC 公交卡": "支持", + "NFC 模拟门卡": "支持", + "NFC 空白门卡": "支持", + "NFC 车钥匙": "支持", + "消息提醒": "独立通信(仅eSIM版手表支持)", + "支持": "支持(需连接Wi-Fi或eSIM网络)", + "小组件": "支持", + "勿扰模式同步": "部分支持", + "息屏显示": "支持", + "设备密码": "支持", + "双手模式": "支持", + "双键截屏": "支持", + "消息隐私模式": "支持", + "超级岛(焦点信息提醒)": "部分支持", + "融合设备中心": "部分支持", + "快捷手势(自定义手势)": "部分支持", + "快捷手势(控制其他设备)": "部分支持", + "颜色": "黑色 / 银色 / 午夜蓝 / 锻造碳 / eSIM", + "通用": "通用", + "卖点": "限时闪降100元,至高享3期免息,以旧换新至高补贴135元 21天超长续航 | 小米澎湃OS 3 | 全新骑行体验", + "概述页": "https://www.mi.com/prod/xiaomi-watch-s5", + "参数页": "https://www.mi.com/prod/xiaomi-watch-s5/specs", + "来源": "小米商城 product_id=23408" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.png", + "gallery/04-detail.png", + "gallery/05-detail.png", + "gallery/06-detail.png", + "gallery/07-detail.png", + "gallery/08-detail.png", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png", + "overview/050-overview.png", + "overview/051-overview.png" + ], + "imageCount": 61, + "galleryCount": 9, + "overviewCount": 52, + "overviewImgPath": null + }, + { + "productId": 22153, + "sku": "SKU-WEAR-004", + "slug": "redmi-watch-6", + "name": "REDMI Watch 6", + "unitPrice": 569, + "description": "至高享3期免息,以旧换新至高补贴135元 2.07英寸AMOLED大屏 | 搭载HyperOS 3.0 | 轻薄一体设计", + "brand": "REDMI", + "salesCount": 34153, + "parameterList": [ + { + "name": "加速度传感器", + "value": "* 5ATM 防水:依据 GB/T 30106:2013测试标准,通过南德认证检测机构检测,", + "highlight": false, + "source": "specs-page" + }, + { + "name": "陀螺仪传感器", + "value": "REDMI Watch 6 防水等级报告编号:68.189.25.0452.02。防水等级5ATM(相当于水下", + "highlight": false, + "source": "specs-page" + }, + { + "name": "光学心率及血氧传感器", + "value": "50 米深度)。支持在泳池游泳、浅滩游泳及其他浅水域的水上活动时佩戴,但不适", + "highlight": false, + "source": "specs-page" + }, + { + "name": "环境光传感器", + "value": "用于热水淋浴、桑拿和深度潜水,并需避免在水上运动时高速水流的直接冲击。防水功", + "highlight": false, + "source": "specs-page" + }, + { + "name": "地磁传感器", + "value": "能并非永久有效,可能随着时间推移而减弱。", + "highlight": false, + "source": "specs-page" + }, + { + "name": "日常活动记录", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "心率监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "血氧监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "睡眠监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "压力监测", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "女性健康", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "呼吸放松", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "150+运动模式", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "自动识别运动开始", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "运动轨迹跟踪", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "运动能量消耗", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "活力指标达标提醒", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "表盘市场", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "独立音乐播放", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "手机音乐控制", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "录音机", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "网络对讲", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "指南针", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "闹钟", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "倒计时", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "秒表", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "手电筒", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "天气", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "待办", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "查找手机", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "世界时钟", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "遥控拍照", + "value": "部分机型支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "米家", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "日历", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "日程", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "微信扫码支付", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "支付宝扫码支付", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "支付宝碰一碰支付", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC公交卡", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC车钥匙", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC模拟门卡", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "NFC空白门卡", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "小部件", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "抬腕亮屏", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "勿扰模式", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "息屏显示", + "value": "支持", + "highlight": false, + "source": "specs-page" + }, + { + "name": "设备密码", + "value": "支持", + "highlight": false, + "source": "specs-page" + } + ], + "specsPageGroups": [ + { + "items": [ + { + "name": "加速度传感器", + "value": "* 5ATM 防水:依据 GB/T 30106:2013测试标准,通过南德认证检测机构检测," + }, + { + "name": "陀螺仪传感器", + "value": "REDMI Watch 6 防水等级报告编号:68.189.25.0452.02。防水等级5ATM(相当于水下" + }, + { + "name": "光学心率及血氧传感器", + "value": "50 米深度)。支持在泳池游泳、浅滩游泳及其他浅水域的水上活动时佩戴,但不适" + }, + { + "name": "环境光传感器", + "value": "用于热水淋浴、桑拿和深度潜水,并需避免在水上运动时高速水流的直接冲击。防水功" + }, + { + "name": "地磁传感器", + "value": "能并非永久有效,可能随着时间推移而减弱。" + } + ] + }, + { + "items": [ + { + "name": "日常活动记录", + "value": "支持" + }, + { + "name": "心率监测", + "value": "支持" + }, + { + "name": "血氧监测", + "value": "支持" + }, + { + "name": "睡眠监测", + "value": "支持" + }, + { + "name": "压力监测", + "value": "支持" + }, + { + "name": "女性健康", + "value": "支持" + }, + { + "name": "呼吸放松", + "value": "支持" + } + ] + }, + { + "items": [ + { + "name": "150+运动模式", + "value": "支持" + }, + { + "name": "自动识别运动开始", + "value": "支持" + }, + { + "name": "运动轨迹跟踪", + "value": "支持" + }, + { + "name": "运动能量消耗", + "value": "支持" + }, + { + "name": "活力指标达标提醒", + "value": "支持" + } + ] + }, + { + "items": [ + { + "name": "表盘市场", + "value": "支持" + }, + { + "name": "独立音乐播放", + "value": "支持" + }, + { + "name": "手机音乐控制", + "value": "支持" + }, + { + "name": "录音机", + "value": "支持" + }, + { + "name": "网络对讲", + "value": "支持" + }, + { + "name": "指南针", + "value": "支持" + }, + { + "name": "闹钟", + "value": "支持" + }, + { + "name": "倒计时", + "value": "支持" + }, + { + "name": "秒表", + "value": "支持" + }, + { + "name": "手电筒", + "value": "支持" + }, + { + "name": "天气", + "value": "支持" + }, + { + "name": "待办", + "value": "支持" + }, + { + "name": "查找手机", + "value": "支持" + }, + { + "name": "世界时钟", + "value": "支持" + }, + { + "name": "遥控拍照", + "value": "部分机型支持" + }, + { + "name": "米家", + "value": "支持" + }, + { + "name": "日历", + "value": "支持" + }, + { + "name": "日程", + "value": "支持" + } + ] + }, + { + "items": [ + { + "name": "微信扫码支付", + "value": "支持" + }, + { + "name": "支付宝扫码支付", + "value": "支持" + }, + { + "name": "支付宝碰一碰支付", + "value": "支持" + }, + { + "name": "NFC公交卡", + "value": "支持" + }, + { + "name": "NFC车钥匙", + "value": "支持" + }, + { + "name": "NFC模拟门卡", + "value": "支持" + }, + { + "name": "NFC空白门卡", + "value": "支持" + } + ] + }, + { + "items": [ + { + "name": "小部件", + "value": "支持" + }, + { + "name": "抬腕亮屏", + "value": "支持" + }, + { + "name": "勿扰模式", + "value": "支持" + }, + { + "name": "息屏显示", + "value": "支持" + }, + { + "name": "设备密码", + "value": "支持" + } + ] + } + ], + "specsOfficialUrl": "https://www.mi.com/prod/redmi-watch-6/specs", + "buyPageParameterCount": 0, + "specsPageParameterCount": 47, + "specifications": { + "加速度传感器": "* 5ATM 防水:依据 GB/T 30106:2013测试标准,通过南德认证检测机构检测,", + "陀螺仪传感器": "REDMI Watch 6 防水等级报告编号:68.189.25.0452.02。防水等级5ATM(相当于水下", + "光学心率及血氧传感器": "50 米深度)。支持在泳池游泳、浅滩游泳及其他浅水域的水上活动时佩戴,但不适", + "环境光传感器": "用于热水淋浴、桑拿和深度潜水,并需避免在水上运动时高速水流的直接冲击。防水功", + "地磁传感器": "能并非永久有效,可能随着时间推移而减弱。", + "日常活动记录": "支持", + "心率监测": "支持", + "血氧监测": "支持", + "睡眠监测": "支持", + "压力监测": "支持", + "女性健康": "支持", + "呼吸放松": "支持", + "150+运动模式": "支持", + "自动识别运动开始": "支持", + "运动轨迹跟踪": "支持", + "运动能量消耗": "支持", + "活力指标达标提醒": "支持", + "表盘市场": "支持", + "独立音乐播放": "支持", + "手机音乐控制": "支持", + "录音机": "支持", + "网络对讲": "支持", + "指南针": "支持", + "闹钟": "支持", + "倒计时": "支持", + "秒表": "支持", + "手电筒": "支持", + "天气": "支持", + "待办": "支持", + "查找手机": "支持", + "世界时钟": "支持", + "遥控拍照": "部分机型支持", + "米家": "支持", + "日历": "支持", + "日程": "支持", + "微信扫码支付": "支持", + "支付宝扫码支付": "支持", + "支付宝碰一碰支付": "支持", + "NFC公交卡": "支持", + "NFC车钥匙": "支持", + "NFC模拟门卡": "支持", + "NFC空白门卡": "支持", + "小部件": "支持", + "抬腕亮屏": "支持", + "勿扰模式": "支持", + "息屏显示": "支持", + "设备密码": "支持", + "颜色": "皎月银 / 典雅黑 / 迷雾蓝", + "通用": "通用", + "卖点": "至高享3期免息,以旧换新至高补贴135元 2.07英寸AMOLED大屏 | 搭载HyperOS 3.0 | 轻薄一体设计", + "概述页": "https://www.mi.com/prod/redmi-watch-6", + "参数页": "https://www.mi.com/prod/redmi-watch-6/specs", + "来源": "小米商城 product_id=22153" + }, + "galleryFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg" + ], + "overviewFiles": [ + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png" + ], + "imageFiles": [ + "gallery/00-gallery.png", + "gallery/01-cover.png", + "gallery/02-detail.png", + "gallery/03-detail.jpg", + "gallery/04-detail.jpg", + "gallery/05-detail.jpg", + "gallery/06-detail.jpg", + "gallery/07-detail.jpg", + "gallery/08-detail.jpg", + "overview/000-overview.png", + "overview/001-overview.png", + "overview/002-overview.png", + "overview/003-overview.png", + "overview/004-overview.png", + "overview/005-overview.png", + "overview/006-overview.png", + "overview/007-overview.png", + "overview/008-overview.png", + "overview/009-overview.png", + "overview/010-overview.png", + "overview/011-overview.png", + "overview/012-overview.png", + "overview/013-overview.png", + "overview/014-overview.png", + "overview/015-overview.png", + "overview/016-overview.png", + "overview/017-overview.png", + "overview/018-overview.png", + "overview/019-overview.png", + "overview/020-overview.png", + "overview/021-overview.png", + "overview/022-overview.png", + "overview/023-overview.png", + "overview/024-overview.png", + "overview/025-overview.png", + "overview/026-overview.png", + "overview/027-overview.png", + "overview/028-overview.png", + "overview/029-overview.png", + "overview/030-overview.png", + "overview/031-overview.png", + "overview/032-overview.png", + "overview/033-overview.png", + "overview/034-overview.png", + "overview/035-overview.png", + "overview/036-overview.png", + "overview/037-overview.png", + "overview/038-overview.png", + "overview/039-overview.png", + "overview/040-overview.png", + "overview/041-overview.png", + "overview/042-overview.png", + "overview/043-overview.png", + "overview/044-overview.png", + "overview/045-overview.png", + "overview/046-overview.png", + "overview/047-overview.png", + "overview/048-overview.png", + "overview/049-overview.png" + ], + "imageCount": 59, + "galleryCount": 9, + "overviewCount": 50, + "overviewImgPath": null + } + ] +} \ No newline at end of file -- Gitee From 3ea0951586586e4af33a89df773a2ddb0a922e94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:51:39 +0800 Subject: [PATCH 05/33] fix(backend): enforce commerce validation and etags --- .../Middleware/ApiExceptionMiddleware.cs | 18 +++++ .../EShop.Modules.Cart/Api/CartEndpoints.cs | 32 +-------- .../Application/UseCases/CartUseCases.cs | 3 +- .../Api/CatalogAdminEndpoints.cs | 2 +- .../Api/CatalogImageEndpoints.cs | 20 +----- .../Api/CatalogInventoryEndpoints.cs | 4 +- .../Api/CatalogReviewEndpoints.cs | 14 +++- .../Api/MerchantProductEndpoints.cs | 20 +----- .../Api/MerchantProductImageEndpoints.cs | 20 +----- .../UseCases/CatalogInventoryUseCases.cs | 29 +------- .../UseCases/CatalogReviewUseCases.cs | 13 ++++ .../Api/CouponEndpoints.cs | 2 +- .../Application/UseCases/CouponUseCases.cs | 11 +-- .../Api/IdentityEndpoints.cs | 20 +----- .../Api/OrderingEndpoints.cs | 2 +- .../Application/UseCases/OrderingUseCases.cs | 41 ++++------- .../Api/PaymentEndpoints.cs | 2 +- .../Application/UseCases/PaymentUseCases.cs | 32 +++------ .../Api/SettlementEndpoints.cs | 6 +- .../Application/Ports/SettlementPort.cs | 9 ++- .../UseCases/SettlementUseCases.cs | 22 +----- .../EShop.Modules.Shop/Api/ShopEndpoints.cs | 10 +-- .../Application/UseCases/ShopUseCases.cs | 10 +-- .../src/EShop.SharedKernel/StrongEntityTag.cs | 69 +++++++++++++++++++ .../ApiExceptionMiddlewareTests.cs | 1 + .../Modules/Cart/GetCartUseCaseTests.cs | 1 + .../Catalog/CatalogInventoryUseCaseTests.cs | 5 +- .../Ordering/OrderingInputValidationTests.cs | 28 ++++++++ .../ResourceNumberConflictRecoveryTests.cs | 12 ++-- .../Payment/BatchPaymentUseCaseTests.cs | 66 ++++++++++++++++++ .../Payment/PaymentTimeoutRaceTests.cs | 18 ++--- .../Modules/Settlement/SettlementPortTests.cs | 18 +++++ .../Settlement/SettlementVersionGuardTests.cs | 6 +- .../SettlementWithdrawalUseCaseTests.cs | 12 ++-- .../Shop/ShopLifecycleAtomicityTests.cs | 4 +- .../SharedKernel/StrongEntityTagTests.cs | 45 ++++++++++++ 36 files changed, 355 insertions(+), 272 deletions(-) create mode 100644 backend/src/EShop.SharedKernel/StrongEntityTag.cs create mode 100644 backend/tests/EShop.Tests.Unit/SharedKernel/StrongEntityTagTests.cs diff --git a/backend/src/EShop.Api/Middleware/ApiExceptionMiddleware.cs b/backend/src/EShop.Api/Middleware/ApiExceptionMiddleware.cs index 4611e04..937776f 100644 --- a/backend/src/EShop.Api/Middleware/ApiExceptionMiddleware.cs +++ b/backend/src/EShop.Api/Middleware/ApiExceptionMiddleware.cs @@ -68,6 +68,23 @@ public sealed class ApiExceptionMiddleware( "COMMON_CONCURRENCY_CONFLICT", "资源已被其他请求修改,请刷新后重试。"); } + catch (DbUpdateException exception) + when (exception.InnerException is Npgsql.PostgresException + { + SqlState: "23505", + ConstraintName: "uk_product_review_order_product" + }) + { + logger.LogInformation( + "Duplicate review rejected for trace {TraceId}.", + ApiResponse.GetTraceId(context)); + + await WriteErrorUnlessStartedAsync( + context, + StatusCodes.Status409Conflict, + "ALREADY_REVIEWED", + "该订单中的商品已经评价过。"); + } catch (DbUpdateException exception) when (exception.InnerException is Npgsql.PostgresException { SqlState: "23505" }) { @@ -133,6 +150,7 @@ public sealed class ApiExceptionMiddleware( case "ORDER_STATE_CONFLICT": case "PAYMENT_STATUS_INVALID": case "COUPON_ALREADY_USED": + case "ALREADY_REVIEWED": return StatusCodes.Status409Conflict; case "COMMON_CONCURRENCY_CONFLICT": return StatusCodes.Status412PreconditionFailed; diff --git a/backend/src/EShop.Modules.Cart/Api/CartEndpoints.cs b/backend/src/EShop.Modules.Cart/Api/CartEndpoints.cs index 20e1e01..014d435 100644 --- a/backend/src/EShop.Modules.Cart/Api/CartEndpoints.cs +++ b/backend/src/EShop.Modules.Cart/Api/CartEndpoints.cs @@ -166,39 +166,11 @@ public static class CartEndpoints /// 由全局异常中间件统一转换为业务 envelope。 /// internal static long ParseRequiredEtag(HttpContext httpContext) - { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim('"', 'v', 'V', ' '); - if (!long.TryParse(raw, out var version) || version < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 \"v<版本号>\""); - } - - return version; - } + => StrongEntityTag.ParseRequired(httpContext.Request.Headers.IfMatch.ToString()); /// 解析可选的 If-Match 头,用于 optional 并发控制的接口 internal static bool TryParseOptionalEtag(HttpContext httpContext, out long version) - { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(ifMatch)) - { - version = 0; - return false; - } - - var raw = ifMatch.Trim('"', 'v', 'V', ' '); - return long.TryParse(raw, out version) && version >= 0; - } + => StrongEntityTag.TryParseOptional(httpContext.Request.Headers.IfMatch.ToString(), out version); /// 提取 Idempotency-Key 请求头 internal static string? GetIdempotencyKey(HttpContext httpContext) diff --git a/backend/src/EShop.Modules.Cart/Application/UseCases/CartUseCases.cs b/backend/src/EShop.Modules.Cart/Application/UseCases/CartUseCases.cs index 09e84ee..0053fc0 100644 --- a/backend/src/EShop.Modules.Cart/Application/UseCases/CartUseCases.cs +++ b/backend/src/EShop.Modules.Cart/Application/UseCases/CartUseCases.cs @@ -311,7 +311,8 @@ internal static class CartUseCaseHelpers ShopName: info?.ShopName ?? "未知商家"); }).ToList(); - return (new CartResponse(items, items.Count, FormatMoney(estimatedTotal), serverNow), etag); + var totalQuantity = items.Sum(item => item.Quantity); + return (new CartResponse(items, totalQuantity, FormatMoney(estimatedTotal), serverNow), etag); } private static DateTime UtcNow(TimeProvider timeProvider) diff --git a/backend/src/EShop.Modules.Catalog/Api/CatalogAdminEndpoints.cs b/backend/src/EShop.Modules.Catalog/Api/CatalogAdminEndpoints.cs index a0af6c9..a0edaab 100644 --- a/backend/src/EShop.Modules.Catalog/Api/CatalogAdminEndpoints.cs +++ b/backend/src/EShop.Modules.Catalog/Api/CatalogAdminEndpoints.cs @@ -348,5 +348,5 @@ public static class CatalogAdminEndpoints /// internal static long ParseRequiredEtag(HttpContext httpContext) => CatalogInventoryManagementUseCase.ParseRequiredEtag( - httpContext.Request.Headers.IfMatch.FirstOrDefault()); + httpContext.Request.Headers.IfMatch.ToString()); } diff --git a/backend/src/EShop.Modules.Catalog/Api/CatalogImageEndpoints.cs b/backend/src/EShop.Modules.Catalog/Api/CatalogImageEndpoints.cs index 26b86c8..00226c1 100644 --- a/backend/src/EShop.Modules.Catalog/Api/CatalogImageEndpoints.cs +++ b/backend/src/EShop.Modules.Catalog/Api/CatalogImageEndpoints.cs @@ -226,23 +226,5 @@ public static class CatalogImageEndpoints /// 由全局异常中间件统一转换为业务 envelope。 /// private static long ParseRequiredEtag(HttpContext httpContext) - { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim('"', 'v', 'V', ' '); - if (!long.TryParse(raw, out var version) || version < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 \"v<版本号>\""); - } - - return version; - } + => StrongEntityTag.ParseRequired(httpContext.Request.Headers.IfMatch.ToString()); } diff --git a/backend/src/EShop.Modules.Catalog/Api/CatalogInventoryEndpoints.cs b/backend/src/EShop.Modules.Catalog/Api/CatalogInventoryEndpoints.cs index 086f638..6548df3 100644 --- a/backend/src/EShop.Modules.Catalog/Api/CatalogInventoryEndpoints.cs +++ b/backend/src/EShop.Modules.Catalog/Api/CatalogInventoryEndpoints.cs @@ -76,7 +76,7 @@ public static class CatalogInventoryEndpoints productId, request, CatalogInventoryOperationIds.AdminAdjustInventory, - httpContext.Request.Headers.IfMatch.FirstOrDefault(), + httpContext.Request.Headers.IfMatch.ToString(), ParseRequiredIdempotencyKey(httpContext), GetUserId(httpContext), "admin", @@ -163,7 +163,7 @@ public static class CatalogInventoryEndpoints productId, request, CatalogInventoryOperationIds.MerchantAdjustInventory, - httpContext.Request.Headers.IfMatch.FirstOrDefault(), + httpContext.Request.Headers.IfMatch.ToString(), ParseRequiredIdempotencyKey(httpContext), merchantId, "merchant", diff --git a/backend/src/EShop.Modules.Catalog/Api/CatalogReviewEndpoints.cs b/backend/src/EShop.Modules.Catalog/Api/CatalogReviewEndpoints.cs index 6b9e9c2..257f2e2 100644 --- a/backend/src/EShop.Modules.Catalog/Api/CatalogReviewEndpoints.cs +++ b/backend/src/EShop.Modules.Catalog/Api/CatalogReviewEndpoints.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Claims; using EShop.Modules.Catalog.Application.Dtos; using EShop.Modules.Catalog.Application.UseCases; @@ -91,8 +92,17 @@ public static class CatalogReviewEndpoints internal static long ParsePositiveId(string raw, string label) { - if (!long.TryParse(raw, out var id) || id <= 0) - throw new SharedKernel.DomainException("INVALID_ID", $"{label} 格式无效: {raw}"); + if (string.IsNullOrEmpty(raw) + || raw[0] == '0' + || raw.Any(character => character is < '0' or > '9') + || !long.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out var id) + || id <= 0) + { + throw new SharedKernel.DomainException( + "COMMON_VALIDATION_FAILED", + $"{label} 必须为正十进制整数字符串。"); + } + return id; } } diff --git a/backend/src/EShop.Modules.Catalog/Api/MerchantProductEndpoints.cs b/backend/src/EShop.Modules.Catalog/Api/MerchantProductEndpoints.cs index 1ec8b82..8bad1cc 100644 --- a/backend/src/EShop.Modules.Catalog/Api/MerchantProductEndpoints.cs +++ b/backend/src/EShop.Modules.Catalog/Api/MerchantProductEndpoints.cs @@ -168,23 +168,5 @@ public static class MerchantProductEndpoints } internal static long ParseRequiredEtag(HttpContext httpContext) - { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim('"', 'v', 'V', ' '); - if (!long.TryParse(raw, out var version) || version < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 \"v<版本号>\""); - } - - return version; - } + => StrongEntityTag.ParseRequired(httpContext.Request.Headers.IfMatch.ToString()); } diff --git a/backend/src/EShop.Modules.Catalog/Api/MerchantProductImageEndpoints.cs b/backend/src/EShop.Modules.Catalog/Api/MerchantProductImageEndpoints.cs index 3d51739..3b713a3 100644 --- a/backend/src/EShop.Modules.Catalog/Api/MerchantProductImageEndpoints.cs +++ b/backend/src/EShop.Modules.Catalog/Api/MerchantProductImageEndpoints.cs @@ -115,23 +115,5 @@ public static class MerchantProductImageEndpoints } private static long ParseRequiredEtag(HttpContext httpContext) - { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim('"', 'v', 'V', ' '); - if (!long.TryParse(raw, out var version) || version < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 \"v<版本号>\""); - } - - return version; - } + => StrongEntityTag.ParseRequired(httpContext.Request.Headers.IfMatch.ToString()); } diff --git a/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogInventoryUseCases.cs b/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogInventoryUseCases.cs index 2b0bd3b..c1bc993 100644 --- a/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogInventoryUseCases.cs +++ b/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogInventoryUseCases.cs @@ -919,34 +919,7 @@ public sealed class CatalogInventoryManagementUseCase : ICatalogInventoryManagem => value?.ToString(CultureInfo.InvariantCulture); internal static long ParseRequiredEtag(string? ifMatch) - { - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim(); - var tag = raw.Length >= 2 && raw[0] == '"' && raw[^1] == '"' - ? raw.AsSpan(1, raw.Length - 2) - : raw.AsSpan(); - if (tag.Length < 2 - || tag[0] != 'v' - || !long.TryParse( - tag[1..], - NumberStyles.None, - CultureInfo.InvariantCulture, - out var version) - || version < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 v<版本号> 或 \"v<版本号>\""); - } - - return version; - } + => StrongEntityTag.ParseRequired(ifMatch); private static async Task RollbackAndDisposeAsync( IDbContextTransaction? transaction) diff --git a/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogReviewUseCases.cs b/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogReviewUseCases.cs index 3bc848e..18d0aa7 100644 --- a/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogReviewUseCases.cs +++ b/backend/src/EShop.Modules.Catalog/Application/UseCases/CatalogReviewUseCases.cs @@ -115,6 +115,18 @@ public class ListProductReviewsUseCase : IListProductReviewsUseCase public async Task<(List Items, int Total, ProductReviewStats Stats)> ExecuteAsync( long productId, int page, int pageSize, CancellationToken ct = default) { + var productExists = await _db.Set() + .AsNoTracking() + .AnyAsync( + product => product.Id == productId + && product.DeletedAt == null + && product.Status == ProductStatus.Active, + ct); + if (!productExists) + { + throw new DomainException("RESOURCE_NOT_FOUND", $"商品 {productId} 不存在或不可见"); + } + var query = _db.Set() .AsNoTracking() .Where(r => r.ProductId == productId); @@ -126,6 +138,7 @@ public class ListProductReviewsUseCase : IListProductReviewsUseCase var items = await query .OrderByDescending(r => r.CreatedAt) + .ThenByDescending(r => r.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(ct); diff --git a/backend/src/EShop.Modules.Coupon/Api/CouponEndpoints.cs b/backend/src/EShop.Modules.Coupon/Api/CouponEndpoints.cs index c11a622..2cf580f 100644 --- a/backend/src/EShop.Modules.Coupon/Api/CouponEndpoints.cs +++ b/backend/src/EShop.Modules.Coupon/Api/CouponEndpoints.cs @@ -136,7 +136,7 @@ public static class CouponEndpoints GetUserId(httpContext), couponId, request.Status, - httpContext.Request.Headers.IfMatch.FirstOrDefault(), + httpContext.Request.Headers.IfMatch.ToString(), httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{etag}\""; return TypedResults.Ok(data); diff --git a/backend/src/EShop.Modules.Coupon/Application/UseCases/CouponUseCases.cs b/backend/src/EShop.Modules.Coupon/Application/UseCases/CouponUseCases.cs index be7ae16..7dc9116 100644 --- a/backend/src/EShop.Modules.Coupon/Application/UseCases/CouponUseCases.cs +++ b/backend/src/EShop.Modules.Coupon/Application/UseCases/CouponUseCases.cs @@ -471,16 +471,7 @@ internal static partial class CouponUseCaseHelpers } public static void EnforceVersion(string? ifMatch, long version) - { - if (string.IsNullOrWhiteSpace(ifMatch)) - throw new DomainException("COMMON_PRECONDITION_REQUIRED", "缺少 If-Match 请求头"); - var normalized = ifMatch.Trim(); - if (normalized.StartsWith("W/", StringComparison.OrdinalIgnoreCase)) - throw new DomainException("COMMON_CONCURRENCY_CONFLICT", "必须使用强 ETag"); - normalized = normalized.Trim('"'); - if (!string.Equals(normalized, $"v{version}", StringComparison.Ordinal)) - throw new DomainException("COMMON_CONCURRENCY_CONFLICT", "优惠券已被其他请求修改"); - } + => StrongEntityTag.EnsureMatches(ifMatch, version); public static CouponTemplateResponse MapTemplate(CouponTemplate template) => new( template.Id.ToString(CultureInfo.InvariantCulture), diff --git a/backend/src/EShop.Modules.Identity/Api/IdentityEndpoints.cs b/backend/src/EShop.Modules.Identity/Api/IdentityEndpoints.cs index d940244..c935ce5 100644 --- a/backend/src/EShop.Modules.Identity/Api/IdentityEndpoints.cs +++ b/backend/src/EShop.Modules.Identity/Api/IdentityEndpoints.cs @@ -554,25 +554,7 @@ public static class IdentityEndpoints /// 由全局异常中间件统一转换为业务 envelope。 /// internal static long ParseRequiredEtag(HttpContext httpContext) - { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim('"', 'v', 'V', ' '); - if (!long.TryParse(raw, out var version) || version < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 \"v<版本号>\""); - } - - return version; - } + => StrongEntityTag.ParseRequired(httpContext.Request.Headers.IfMatch.ToString()); private static void SetRefreshCookie(HttpContext httpContext, string token, DateTime expiresAt) { diff --git a/backend/src/EShop.Modules.Ordering/Api/OrderingEndpoints.cs b/backend/src/EShop.Modules.Ordering/Api/OrderingEndpoints.cs index 197b2d3..21e6782 100644 --- a/backend/src/EShop.Modules.Ordering/Api/OrderingEndpoints.cs +++ b/backend/src/EShop.Modules.Ordering/Api/OrderingEndpoints.cs @@ -578,7 +578,7 @@ public static class OrderingEndpoints /// 均由取得 PENDING owner/fence 的应用用例在幂等检查之后验证。 /// internal static string? GetIfMatch(HttpContext httpContext) - => httpContext.Request.Headers.IfMatch.FirstOrDefault(); + => httpContext.Request.Headers.IfMatch.ToString(); internal static string? GetIdempotencyKey(HttpContext httpContext) => httpContext.Request.Headers["Idempotency-Key"].FirstOrDefault(); diff --git a/backend/src/EShop.Modules.Ordering/Application/UseCases/OrderingUseCases.cs b/backend/src/EShop.Modules.Ordering/Application/UseCases/OrderingUseCases.cs index b1da427..a35529f 100644 --- a/backend/src/EShop.Modules.Ordering/Application/UseCases/OrderingUseCases.cs +++ b/backend/src/EShop.Modules.Ordering/Application/UseCases/OrderingUseCases.cs @@ -519,6 +519,7 @@ public class CreateOrderUseCase : ICreateOrderUseCase var groupOrderNo = orderNoByMerchantId[merchantId]; var groupItems = new List(); + decimal groupGoodsAmount = 0m; decimal groupPayableAmount = 0m; foreach (var cartItem in group) @@ -537,6 +538,8 @@ public class CreateOrderUseCase : ICreateOrderUseCase OrderingHelpers.EnsureStorableMoney( reservation.BaseUnitPrice * cartItem.Quantity, $"商品 #{cartItem.ProductId} 基础价小计"); + groupGoodsAmount += lineAmount; + OrderingHelpers.EnsureStorableMoney(groupGoodsAmount, "商家订单商品总额"); groupPayableAmount += payableLineAmount; OrderingHelpers.EnsureStorableMoney(groupPayableAmount, "商家订单合计"); @@ -713,6 +716,7 @@ public class ListMyOrdersUseCase : IListMyOrdersUseCase var orders = await query .Include(o => o.Items) .OrderByDescending(o => o.PlacedAt) + .ThenByDescending(o => o.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .ToListAsync(ct); @@ -1130,14 +1134,15 @@ public class AdminListOrdersUseCase : IAdminListOrdersUseCase "COMMON_VALIDATION_FAILED", "from 不能晚于 to"); } + var userId = OrderingHelpers.ParseOptionalPositiveId(userIdFilter, "userId"); var query = _db.Set().AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(orderNo)) query = query.Where(o => o.OrderNo.Contains(orderNo)); - if (!string.IsNullOrWhiteSpace(userIdFilter) && long.TryParse(userIdFilter, out var uid)) - query = query.Where(o => o.UserId == uid); + if (userId is not null) + query = query.Where(o => o.UserId == userId.Value); if (!string.IsNullOrWhiteSpace(status)) query = query.Where(o => o.Status == status); @@ -2409,7 +2414,10 @@ internal static class OrderingHelpers public static long ParsePositiveId(string? rawValue, string fieldName) { - if (!long.TryParse( + if (string.IsNullOrEmpty(rawValue) + || rawValue[0] is < '1' or > '9' + || rawValue.Any(character => character is < '0' or > '9') + || !long.TryParse( rawValue, NumberStyles.None, CultureInfo.InvariantCulture, @@ -2789,32 +2797,7 @@ internal static class OrderingHelpers /// COMPLETED 重放路径不会调用此方法。 /// public static void EnforceVersion(string? ifMatch, long currentVersion) - { - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim().Trim('"'); - var cleaned = raw.Replace("order-", "", StringComparison.OrdinalIgnoreCase) - .Replace("cart-", "", StringComparison.OrdinalIgnoreCase) - .Replace("v", "", StringComparison.OrdinalIgnoreCase); - if (!long.TryParse(cleaned, out var expectedVersion) || expectedVersion < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 \"v<版本号>\""); - } - - if (currentVersion != expectedVersion) - { - throw new DomainException( - "COMMON_CONCURRENCY_CONFLICT", - $"资源已被修改(期望 v{expectedVersion},实际 v{currentVersion}),请刷新后重试"); - } - } + => StrongEntityTag.EnsureMatches(ifMatch, currentVersion); private static Task FindIdempotencyAsync( DbContext db, diff --git a/backend/src/EShop.Modules.Payment/Api/PaymentEndpoints.cs b/backend/src/EShop.Modules.Payment/Api/PaymentEndpoints.cs index aefe1bb..ddd16c1 100644 --- a/backend/src/EShop.Modules.Payment/Api/PaymentEndpoints.cs +++ b/backend/src/EShop.Modules.Payment/Api/PaymentEndpoints.cs @@ -61,7 +61,7 @@ public static class PaymentEndpoints "此操作要求携带 Idempotency-Key 请求头"); } - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var (data, etag) = await useCase.ExecuteAsync( userId, paymentId, diff --git a/backend/src/EShop.Modules.Payment/Application/UseCases/PaymentUseCases.cs b/backend/src/EShop.Modules.Payment/Application/UseCases/PaymentUseCases.cs index 583f731..16778f6 100644 --- a/backend/src/EShop.Modules.Payment/Application/UseCases/PaymentUseCases.cs +++ b/backend/src/EShop.Modules.Payment/Application/UseCases/PaymentUseCases.cs @@ -334,6 +334,8 @@ public class ConfirmPaymentUseCase : IConfirmPaymentUseCase public class BatchPaymentUseCase : IBatchPaymentUseCase { + internal const int MaximumOrderCount = 50; + private readonly DbContext _db; private readonly IOrderPaymentPort _orderPaymentPort; private readonly ISettlementPort _settlementPort; @@ -353,6 +355,10 @@ public class BatchPaymentUseCase : IBatchPaymentUseCase { if (request?.OrderIds is not { Count: > 0 }) throw new DomainException("COMMON_VALIDATION_FAILED", "请选择至少一个订单"); + if (request.OrderIds.Count > MaximumOrderCount) + throw new DomainException( + "COMMON_VALIDATION_FAILED", + $"单次批量支付最多支持 {MaximumOrderCount} 个订单"); var orderIds = ParseOrderIds(request.OrderIds); @@ -502,8 +508,10 @@ public class BatchPaymentUseCase : IBatchPaymentUseCase throw new DomainException("COMMON_VALIDATION_FAILED", "订单 ID 格式无效"); } - if (seen.Add(orderId)) - orderIds.Add(orderId); + if (!seen.Add(orderId)) + throw new DomainException("COMMON_VALIDATION_FAILED", "订单 ID 不能重复"); + + orderIds.Add(orderId); } return orderIds; @@ -742,25 +750,7 @@ internal static class PaymentHelpers } public static long ParseRequiredEtag(string? ifMatch) - { - if (string.IsNullOrWhiteSpace(ifMatch)) - { - throw new DomainException( - "COMMON_PRECONDITION_REQUIRED", - "缺少 If-Match 请求头,请先获取资源最新 ETag"); - } - - var raw = ifMatch.Trim('"', 'v', 'V', ' '); - var cleaned = raw.Replace("payment-", "", StringComparison.OrdinalIgnoreCase); - if (!long.TryParse(cleaned, out var version) || version < 0) - { - throw new DomainException( - "COMMON_BAD_REQUEST", - "If-Match 格式不正确,应为 \"v<版本号>\""); - } - - return version; - } + => StrongEntityTag.ParseRequired(ifMatch); private static async Task EvaluateExistingAsync( DbContext db, diff --git a/backend/src/EShop.Modules.Settlement/Api/SettlementEndpoints.cs b/backend/src/EShop.Modules.Settlement/Api/SettlementEndpoints.cs index f58bff3..e8b8d4a 100644 --- a/backend/src/EShop.Modules.Settlement/Api/SettlementEndpoints.cs +++ b/backend/src/EShop.Modules.Settlement/Api/SettlementEndpoints.cs @@ -120,7 +120,7 @@ public static class SettlementEndpoints { var adminId = GetUserId(httpContext); var idempotencyKey = httpContext.Request.Headers["Idempotency-Key"].FirstOrDefault(); - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var result = await useCase.ExecuteAsync( adminId, id, "approve", null, idempotencyKey, ifMatch, httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{result.Etag}\""; @@ -146,7 +146,7 @@ public static class SettlementEndpoints { var adminId = GetUserId(httpContext); var idempotencyKey = httpContext.Request.Headers["Idempotency-Key"].FirstOrDefault(); - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var result = await useCase.ExecuteAsync( adminId, id, "reject", request.Remark, idempotencyKey, ifMatch, httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{result.Etag}\""; @@ -171,7 +171,7 @@ public static class SettlementEndpoints { var adminId = GetUserId(httpContext); var idempotencyKey = httpContext.Request.Headers["Idempotency-Key"].FirstOrDefault(); - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var result = await useCase.ExecuteAsync( adminId, id, idempotencyKey, ifMatch, httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{result.Etag}\""; diff --git a/backend/src/EShop.Modules.Settlement/Application/Ports/SettlementPort.cs b/backend/src/EShop.Modules.Settlement/Application/Ports/SettlementPort.cs index f5197fb..563a051 100644 --- a/backend/src/EShop.Modules.Settlement/Application/Ports/SettlementPort.cs +++ b/backend/src/EShop.Modules.Settlement/Application/Ports/SettlementPort.cs @@ -24,6 +24,11 @@ public sealed class SettlementPort : Contracts.Ports.ISettlementPort CancellationToken ct) { ct.ThrowIfCancellationRequested(); + if (merchantId == 0) + return Task.CompletedTask; + if (merchantId < 0) + throw new ArgumentOutOfRangeException(nameof(merchantId), "商户 ID 不能为负数"); + var commissionAmount = decimal.Round(orderAmount * CommissionRate, 2); _db.Set().Add(SettlementRecord.Create( merchantId, @@ -41,7 +46,7 @@ public sealed class SettlementPort : Contracts.Ports.ISettlementPort .FirstOrDefaultAsync(r => r.OrderId == orderId, ct); // 不存在结算记录(如商户 0 的平台自营单)或净额 <= 0 则跳过 - if (record is null || record.NetAmount <= 0) + if (record is null || record.MerchantId <= 0 || record.NetAmount <= 0) return; if (record.Status != SettlementStatus.Pending) @@ -67,7 +72,7 @@ public sealed class SettlementPort : Contracts.Ports.ISettlementPort var record = await _db.Set() .FirstOrDefaultAsync(r => r.OrderId == orderId, ct); - if (record is null) return; + if (record is null || record.MerchantId <= 0) return; if (record.Status == SettlementStatus.Pending) { diff --git a/backend/src/EShop.Modules.Settlement/Application/UseCases/SettlementUseCases.cs b/backend/src/EShop.Modules.Settlement/Application/UseCases/SettlementUseCases.cs index 9e3e361..403cf6b 100644 --- a/backend/src/EShop.Modules.Settlement/Application/UseCases/SettlementUseCases.cs +++ b/backend/src/EShop.Modules.Settlement/Application/UseCases/SettlementUseCases.cs @@ -74,27 +74,7 @@ internal static class SettlementHelpers => value.ToString("F2", CultureInfo.InvariantCulture); public static void EnforceVersion(string? ifMatch, long entityVersion) - { - if (string.IsNullOrWhiteSpace(ifMatch)) - throw new DomainException("COMMON_PRECONDITION_REQUIRED", "缺少 If-Match 资源版本"); - - var candidate = ifMatch.Trim(); - if (candidate.Length >= 2 && candidate[0] == '"' && candidate[^1] == '"') - candidate = candidate[1..^1]; - - if (candidate.Length < 2 - || (candidate[0] != 'v' && candidate[0] != 'V') - || !long.TryParse( - candidate.AsSpan(1), - NumberStyles.None, - CultureInfo.InvariantCulture, - out var expectedVersion)) - throw new DomainException("COMMON_VALIDATION_FAILED", $"无效的 ETag 格式: {ifMatch}"); - - if (expectedVersion != entityVersion) - throw new DomainException("COMMON_CONCURRENCY_CONFLICT", - $"资源版本冲突:期望 v{expectedVersion},实际 v{entityVersion}"); - } + => StrongEntityTag.EnsureMatches(ifMatch, entityVersion); public static DomainException WithdrawalStateConflict(InvalidOperationException exception) => new("WITHDRAWAL_STATE_CONFLICT", exception.Message, exception); diff --git a/backend/src/EShop.Modules.Shop/Api/ShopEndpoints.cs b/backend/src/EShop.Modules.Shop/Api/ShopEndpoints.cs index f06e2bd..ad512d5 100644 --- a/backend/src/EShop.Modules.Shop/Api/ShopEndpoints.cs +++ b/backend/src/EShop.Modules.Shop/Api/ShopEndpoints.cs @@ -134,7 +134,7 @@ public static class ShopEndpoints IUpdateShopProfileUseCase useCase) => { var userId = GetUserId(httpContext); - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var result = await useCase.ExecuteAsync(userId, request, ifMatch, httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{result.Etag}\""; return TypedResults.Ok(result); @@ -154,7 +154,7 @@ public static class ShopEndpoints IUploadShopLogoUseCase useCase) => { var userId = GetUserId(httpContext); - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); IFormFile? file = null; if (httpContext.Request.HasFormContentType) @@ -307,7 +307,7 @@ public static class ShopEndpoints HttpContext httpContext, IAdminSuspendShopUseCase useCase) => { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var result = await useCase.ExecuteAsync(shopId, ifMatch, httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{result.Etag}\""; return TypedResults.Ok(result); @@ -328,7 +328,7 @@ public static class ShopEndpoints HttpContext httpContext, IAdminCloseShopUseCase useCase) => { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var result = await useCase.ExecuteAsync(shopId, ifMatch, httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{result.Etag}\""; return TypedResults.Ok(result); @@ -349,7 +349,7 @@ public static class ShopEndpoints HttpContext httpContext, IAdminReactivateShopUseCase useCase) => { - var ifMatch = httpContext.Request.Headers.IfMatch.FirstOrDefault(); + var ifMatch = httpContext.Request.Headers.IfMatch.ToString(); var result = await useCase.ExecuteAsync(shopId, ifMatch, httpContext.RequestAborted); httpContext.Response.Headers.ETag = $"\"{result.Etag}\""; return TypedResults.Ok(result); diff --git a/backend/src/EShop.Modules.Shop/Application/UseCases/ShopUseCases.cs b/backend/src/EShop.Modules.Shop/Application/UseCases/ShopUseCases.cs index b5c3b13..fc9d4dc 100644 --- a/backend/src/EShop.Modules.Shop/Application/UseCases/ShopUseCases.cs +++ b/backend/src/EShop.Modules.Shop/Application/UseCases/ShopUseCases.cs @@ -847,15 +847,7 @@ internal static class ShopVersionGuard { public static void EnforceIfMatch(EntityEntry entry, string? ifMatch, long currentVersion) { - if (string.IsNullOrWhiteSpace(ifMatch)) - throw new DomainException("COMMON_PRECONDITION_REQUIRED", "缺少 If-Match 请求头,请先获取资源最新 ETag"); - - var raw = ifMatch.Trim().Trim('"'); - var cleaned = raw.Replace("v", "", StringComparison.OrdinalIgnoreCase); - if (!long.TryParse(cleaned, NumberStyles.None, CultureInfo.InvariantCulture, out var expectedVersion) - || expectedVersion < 0) - throw new DomainException("COMMON_BAD_REQUEST", "If-Match 格式不正确,应为 \"v<版本号>\""); - + var expectedVersion = StrongEntityTag.ParseRequired(ifMatch); entry.Property("Version").OriginalValue = expectedVersion; } } diff --git a/backend/src/EShop.SharedKernel/StrongEntityTag.cs b/backend/src/EShop.SharedKernel/StrongEntityTag.cs new file mode 100644 index 0000000..508f472 --- /dev/null +++ b/backend/src/EShop.SharedKernel/StrongEntityTag.cs @@ -0,0 +1,69 @@ +using System.Globalization; + +namespace EShop.SharedKernel; + +/// +/// Parses the HTTP representation of an entity version. The wire format is a +/// quoted, strong entity tag: "v<non-negative integer>". +/// +public static class StrongEntityTag +{ + public static long ParseRequired(string? ifMatch) + { + if (string.IsNullOrWhiteSpace(ifMatch)) + { + throw new DomainException( + "COMMON_PRECONDITION_REQUIRED", + "缺少 If-Match 请求头,请先获取资源最新 ETag"); + } + + var candidate = ifMatch.Trim(); + if (candidate.Length < 4 + || candidate[0] != '"' + || candidate[^1] != '"') + { + throw InvalidFormat(); + } + + var tag = candidate.AsSpan(1, candidate.Length - 2); + if (tag.Length < 2 + || tag[0] != 'v' + || !long.TryParse( + tag[1..], + NumberStyles.None, + CultureInfo.InvariantCulture, + out var version) + || version < 0) + { + throw InvalidFormat(); + } + + return version; + } + + public static bool TryParseOptional(string? ifMatch, out long version) + { + if (string.IsNullOrWhiteSpace(ifMatch)) + { + version = 0; + return false; + } + + version = ParseRequired(ifMatch); + return true; + } + + public static void EnsureMatches(string? ifMatch, long currentVersion) + { + var expectedVersion = ParseRequired(ifMatch); + if (expectedVersion != currentVersion) + { + throw new DomainException( + "COMMON_CONCURRENCY_CONFLICT", + $"资源已被修改(期望 v{expectedVersion},实际 v{currentVersion}),请刷新后重试"); + } + } + + private static DomainException InvalidFormat() + => new("COMMON_BAD_REQUEST", "If-Match 格式不正确,应为 \"v<版本号>\""); +} diff --git a/backend/tests/EShop.Tests.Unit/ApiExceptionMiddlewareTests.cs b/backend/tests/EShop.Tests.Unit/ApiExceptionMiddlewareTests.cs index 1d4adc3..a3e0f91 100644 --- a/backend/tests/EShop.Tests.Unit/ApiExceptionMiddlewareTests.cs +++ b/backend/tests/EShop.Tests.Unit/ApiExceptionMiddlewareTests.cs @@ -91,6 +91,7 @@ public sealed class ApiExceptionMiddlewareTests [InlineData("ORDER_STATE_CONFLICT", StatusCodes.Status409Conflict)] [InlineData("PAYMENT_STATUS_INVALID", StatusCodes.Status409Conflict)] [InlineData("WITHDRAWAL_STATE_CONFLICT", StatusCodes.Status409Conflict)] + [InlineData("ALREADY_REVIEWED", StatusCodes.Status409Conflict)] public async Task Order_and_payment_rejections_use_contract_status( string code, int expectedStatus) diff --git a/backend/tests/EShop.Tests.Unit/Modules/Cart/GetCartUseCaseTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Cart/GetCartUseCaseTests.cs index b80d828..35d3473 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Cart/GetCartUseCaseTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Cart/GetCartUseCaseTests.cs @@ -103,6 +103,7 @@ public sealed class GetCartUseCaseTests Assert.Equal("100.00", item.BasePrice); Assert.Equal("购物节", item.ActivePromotion?.Name); Assert.Equal("160.00", item.Subtotal); + Assert.Equal(2, data.TotalCount); Assert.Equal("160.00", data.EstimatedTotal); } diff --git a/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogInventoryUseCaseTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogInventoryUseCaseTests.cs index 329b1cc..d603579 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogInventoryUseCaseTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogInventoryUseCaseTests.cs @@ -407,9 +407,7 @@ public sealed class CatalogInventoryUseCaseTests [Theory] [InlineData("\"v0\"", 0)] [InlineData("\"v42\"", 42)] - [InlineData("v0", 0)] - [InlineData("v42", 42)] - public void Inventory_etag_parser_accepts_header_and_row_version_formats( + public void Inventory_etag_parser_accepts_strong_header_format( string header, long expectedVersion) { @@ -423,6 +421,7 @@ public sealed class CatalogInventoryUseCaseTests [Theory] [InlineData("1")] + [InlineData("v1")] [InlineData("vv1")] [InlineData("\"v1v\"")] [InlineData("W/\"v1\"")] diff --git a/backend/tests/EShop.Tests.Unit/Modules/Ordering/OrderingInputValidationTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Ordering/OrderingInputValidationTests.cs index 7db96ad..867ccd2 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Ordering/OrderingInputValidationTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Ordering/OrderingInputValidationTests.cs @@ -170,6 +170,34 @@ public sealed class OrderingInputValidationTests Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); } + [Theory] + [InlineData("abc")] + [InlineData("0")] + [InlineData("-1")] + [InlineData("+1")] + [InlineData("1.0")] + [InlineData("001")] + [InlineData(" 1 ")] + [InlineData("9223372036854775808")] + public async Task Admin_order_list_rejects_invalid_user_id_before_query(string userId) + { + var useCase = new AdminListOrdersUseCase(null!, null!, null!); + + var exception = await Assert.ThrowsAsync(() => + useCase.ExecuteAsync(null, userId, null, null, null, 1, 10)); + + Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); + } + + [Fact] + public void Stored_money_guard_rejects_group_totals_above_numeric_limit() + { + var exception = Assert.Throws(() => + OrderingHelpers.EnsureStorableMoney(10_000_000_000m, "商家订单商品总额")); + + Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); + } + [Theory] [InlineData("", "顺丰速运")] [InlineData(" ", "顺丰速运")] diff --git a/backend/tests/EShop.Tests.Unit/Modules/Ordering/ResourceNumberConflictRecoveryTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Ordering/ResourceNumberConflictRecoveryTests.cs index 76da961..f7213bd 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Ordering/ResourceNumberConflictRecoveryTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Ordering/ResourceNumberConflictRecoveryTests.cs @@ -49,7 +49,7 @@ public sealed class ResourceNumberConflictRecoveryTests CartItemIds: ["1", "2"], ClientExpectedAmount: "265.50"), Guid.NewGuid().ToString("D"), - "v0"); + "\"v0\""); var orderNoByProductId = await db.Set() .SelectMany( @@ -97,7 +97,7 @@ public sealed class ResourceNumberConflictRecoveryTests CartItemIds: ["1", "2"], ClientExpectedAmount: "265.50"), Guid.NewGuid().ToString("D"), - "v0"); + "\"v0\""); var order = await db.Set() .Include(candidate => candidate.Items) @@ -136,7 +136,7 @@ public sealed class ResourceNumberConflictRecoveryTests userId: 7, request, idempotencyKey, - "v0")); + "\"v0\"")); Assert.Equal("COMMON_CONFLICT", conflict.Code); Assert.False(await db.Set().AnyAsync()); @@ -147,7 +147,7 @@ public sealed class ResourceNumberConflictRecoveryTests userId: 7, request, idempotencyKey, - "v0"); + "\"v0\""); Assert.NotEqual(interceptor.AttemptedNumber, response.OrderNo); Assert.Single(await db.Set().ToListAsync()); @@ -177,7 +177,7 @@ public sealed class ResourceNumberConflictRecoveryTests CartItemIds: ["1"], ClientExpectedAmount: "88.49"), Guid.NewGuid().ToString("D"), - "v0")); + "\"v0\"")); Assert.Equal("COMMON_INVALID_ARGUMENT", exception.Code); Assert.Contains("订单金额已变更", exception.Message); @@ -258,7 +258,7 @@ public sealed class ResourceNumberConflictRecoveryTests CartItemIds: ["1"], ClientExpectedAmount: "88.50"), Guid.NewGuid().ToString("D"), - "v0")); + "\"v0\"")); Assert.Equal("IDEMPOTENCY_RECOVERY_REQUIRED", exception.Code); Assert.True((await db.Set().SingleAsync()) diff --git a/backend/tests/EShop.Tests.Unit/Modules/Payment/BatchPaymentUseCaseTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Payment/BatchPaymentUseCaseTests.cs index 7c2094b..8575bcc 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Payment/BatchPaymentUseCaseTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Payment/BatchPaymentUseCaseTests.cs @@ -54,6 +54,72 @@ public sealed class BatchPaymentUseCaseTests Assert.Empty(await db.Set().ToListAsync()); } + [Fact] + public async Task Batch_payment_rejects_duplicate_order_ids_before_idempotency() + { + var options = CreateOptions(); + await using var db = new BatchPaymentDbContext(options); + var useCase = new BatchPaymentUseCase( + db, + new StubOrderPaymentPort(new Dictionary()), + new SettlementPort(db)); + + var exception = await Assert.ThrowsAsync(() => + useCase.ExecuteAsync( + 7, + new BatchPayRequest(["101", "101"]), + "01900000-0000-7000-8000-000000000904")); + + Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); + Assert.Empty(await db.Set().ToListAsync()); + } + + [Fact] + public async Task Batch_payment_rejects_more_than_the_supported_order_count() + { + var options = CreateOptions(); + await using var db = new BatchPaymentDbContext(options); + var useCase = new BatchPaymentUseCase( + db, + new StubOrderPaymentPort(new Dictionary()), + new SettlementPort(db)); + var orderIds = Enumerable.Range(1, BatchPaymentUseCase.MaximumOrderCount + 1) + .Select(value => value.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .ToList(); + + var exception = await Assert.ThrowsAsync(() => + useCase.ExecuteAsync( + 7, + new BatchPayRequest(orderIds), + "01900000-0000-7000-8000-000000000905")); + + Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); + Assert.Empty(await db.Set().ToListAsync()); + } + + [Fact] + public async Task Platform_owned_order_is_paid_without_creating_a_settlement() + { + var options = CreateOptions(); + var orders = new Dictionary + { + [101] = new(101, 7, 0, 88.50m, "PENDING_PAYMENT", 0), + }; + await using var db = new BatchPaymentDbContext(options); + var useCase = new BatchPaymentUseCase( + db, + new StubOrderPaymentPort(orders), + new SettlementPort(db)); + + var response = await useCase.ExecuteAsync( + 7, + new BatchPayRequest(["101"]), + "01900000-0000-7000-8000-000000000906"); + + Assert.Single(response.Payments); + Assert.Empty(await db.Set().ToListAsync()); + } + [Fact] public async Task Batch_payment_maps_generated_ids_after_save_and_replays_them() { diff --git a/backend/tests/EShop.Tests.Unit/Modules/Payment/PaymentTimeoutRaceTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Payment/PaymentTimeoutRaceTests.cs index a05b82c..4e1cd9d 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Payment/PaymentTimeoutRaceTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Payment/PaymentTimeoutRaceTests.cs @@ -341,7 +341,7 @@ public sealed class PaymentTimeoutRaceTests userId: 7, paymentId, "01900000-0000-7000-8000-000000000109", - $"v{closedPayment.Version}")); + $"\"v{closedPayment.Version}\"")); Assert.Equal("PAYMENT_STATUS_INVALID", exception.Code); Assert.False(await confirmDb.Set() @@ -381,7 +381,7 @@ public sealed class PaymentTimeoutRaceTests userId: 7, paymentId, "01900000-0000-7000-8000-000000000111", - $"v{processingVersion}"); + $"\"v{processingVersion}\""); } await using var assertDb = new PaymentRaceDbContext(options); @@ -453,7 +453,7 @@ public sealed class PaymentTimeoutRaceTests userId: 8, paymentId, "01900000-0000-7000-8000-000000000113", - "v999999")); + "\"v999999\"")); } DomainException missingError; @@ -468,7 +468,7 @@ public sealed class PaymentTimeoutRaceTests userId: 8, paymentId + 999, "01900000-0000-7000-8000-000000000122", - "v999999")); + "\"v999999\"")); } Assert.Equal("RESOURCE_NOT_FOUND", foreignError.Code); @@ -595,7 +595,7 @@ public sealed class PaymentTimeoutRaceTests orderId, reason: " changed mind ", "01900000-0000-7000-8000-000000000120", - $"v{orderVersion}"); + $"\"v{orderVersion}\""); } long closedVersion; @@ -619,7 +619,7 @@ public sealed class PaymentTimeoutRaceTests userId: 7, paymentId, "01900000-0000-7000-8000-000000000121", - $"v{closedVersion}")); + $"\"v{closedVersion}\"")); Assert.Equal("PAYMENT_STATUS_INVALID", exception.Code); Assert.False(await confirmDb.Set() .AnyAsync(request => @@ -762,7 +762,7 @@ public sealed class PaymentTimeoutRaceTests orderId, idempotencyKey); - return (long.Parse(payment.PaymentId), etag); + return (long.Parse(payment.PaymentId), $"\"{etag}\""); } private static async Task CaptureCancelErrorAsync( @@ -783,7 +783,7 @@ public sealed class PaymentTimeoutRaceTests orderId, reason: null, idempotencyKey, - "v0")); + "\"v0\"")); } private static async Task CaptureReceiptErrorAsync( @@ -799,7 +799,7 @@ public sealed class PaymentTimeoutRaceTests userId, orderId, idempotencyKey, - "v0")); + "\"v0\"")); } private static SalesOrder CreateOrder(string orderNo) diff --git a/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementPortTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementPortTests.cs index 33d7662..e7ccbf5 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementPortTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementPortTests.cs @@ -7,6 +7,24 @@ namespace EShop.Tests.Unit.Modules.Settlement; public sealed class SettlementPortTests { + [Fact] + public async Task Platform_owned_order_does_not_create_settlement_or_wallet() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + + await using var db = new SettlementDbContext(options); + var port = new SettlementPort(db); + + await port.CreatePendingSettlementAsync(101, 0, 100m, CancellationToken.None); + await port.SettleOrderAsync(101, CancellationToken.None); + await port.AdjustSettlementForRefundAsync(101, 50m, CancellationToken.None); + + Assert.Empty(db.Set()); + Assert.Empty(db.Set()); + } + [Fact] public async Task Refund_adjustment_failure_is_propagated_to_the_caller() { diff --git a/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementVersionGuardTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementVersionGuardTests.cs index ca77a7d..818e658 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementVersionGuardTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementVersionGuardTests.cs @@ -18,8 +18,6 @@ public sealed class SettlementVersionGuardTests } [Theory] - [InlineData("v7")] - [InlineData("V7")] [InlineData("\"v7\"")] [InlineData(" \"v7\" ")] public void Strong_version_tags_are_accepted(string ifMatch) @@ -29,6 +27,8 @@ public sealed class SettlementVersionGuardTests [Theory] [InlineData("7")] + [InlineData("v7")] + [InlineData("V7")] [InlineData("vv7")] [InlineData("v7v")] [InlineData("foo v7")] @@ -42,7 +42,7 @@ public sealed class SettlementVersionGuardTests var exception = Assert.Throws(() => SettlementHelpers.EnforceVersion(ifMatch, 7)); - Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); + Assert.Equal("COMMON_BAD_REQUEST", exception.Code); } [Fact] diff --git a/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementWithdrawalUseCaseTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementWithdrawalUseCaseTests.cs index 00d6b87..8f8d5d8 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementWithdrawalUseCaseTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Settlement/SettlementWithdrawalUseCaseTests.cs @@ -59,7 +59,7 @@ public sealed class SettlementWithdrawalUseCaseTests "approve", null, "00000000-0000-0000-0000-000000000002", - "v1")); + "\"v1\"")); Assert.Equal("WITHDRAWAL_STATE_CONFLICT", exception.Code); } @@ -89,7 +89,7 @@ public sealed class SettlementWithdrawalUseCaseTests "reject", "risk rejected", "00000000-0000-0000-0000-000000000003", - "v0")); + "\"v0\"")); Assert.Equal("WITHDRAWAL_STATE_CONFLICT", exception.Code); } @@ -124,7 +124,7 @@ public sealed class SettlementWithdrawalUseCaseTests 91, withdrawalId, "00000000-0000-0000-0000-000000000004", - "v1")); + "\"v1\"")); Assert.Equal("WITHDRAWAL_STATE_CONFLICT", exception.Code); } @@ -235,7 +235,7 @@ public sealed class SettlementWithdrawalUseCaseTests "approve", null, key, - "v0"); + "\"v0\""); } await using var replayDb = new SettlementDbContext(options); @@ -245,7 +245,7 @@ public sealed class SettlementWithdrawalUseCaseTests "approve", null, key, - "v0"); + "\"v0\""); Assert.Equal(first, replay); Assert.Equal(WithdrawalStatus.Approved, replay.Status); @@ -308,7 +308,7 @@ public sealed class SettlementWithdrawalUseCaseTests "reject", new string('x', 501), "00000000-0000-0000-0000-000000000105", - "v0")); + "\"v0\"")); Assert.Equal("COMMON_VALIDATION_FAILED", exception.Code); Assert.Empty(await db.Set().ToListAsync()); diff --git a/backend/tests/EShop.Tests.Unit/Modules/Shop/ShopLifecycleAtomicityTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Shop/ShopLifecycleAtomicityTests.cs index 97ef5ad..f5a03d7 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Shop/ShopLifecycleAtomicityTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Shop/ShopLifecycleAtomicityTests.cs @@ -89,10 +89,10 @@ public sealed class ShopLifecycleAtomicityTests switch (scenario) { case "admin-suspend": - await new AdminSuspendShopUseCase(db, port).ExecuteAsync(shopId, "v0"); + await new AdminSuspendShopUseCase(db, port).ExecuteAsync(shopId, "\"v0\""); break; case "admin-close": - await new AdminCloseShopUseCase(db, port).ExecuteAsync(shopId, "v0"); + await new AdminCloseShopUseCase(db, port).ExecuteAsync(shopId, "\"v0\""); break; case "merchant-suspend": await new SuspendMyShopUseCase(db, AllowShopAccess.Instance, port) diff --git a/backend/tests/EShop.Tests.Unit/SharedKernel/StrongEntityTagTests.cs b/backend/tests/EShop.Tests.Unit/SharedKernel/StrongEntityTagTests.cs new file mode 100644 index 0000000..28b9135 --- /dev/null +++ b/backend/tests/EShop.Tests.Unit/SharedKernel/StrongEntityTagTests.cs @@ -0,0 +1,45 @@ +using EShop.SharedKernel; + +namespace EShop.Tests.Unit.SharedKernel; + +public sealed class StrongEntityTagTests +{ + [Theory] + [InlineData("\"v0\"", 0)] + [InlineData("\"v42\"", 42)] + [InlineData(" \"v7\" ", 7)] + public void ParseRequired_accepts_only_quoted_strong_version_tags(string value, long expected) + => Assert.Equal(expected, StrongEntityTag.ParseRequired(value)); + + [Theory] + [InlineData("7")] + [InlineData("v7")] + [InlineData("\"7\"")] + [InlineData("\"V7\"")] + [InlineData("W/\"v7\"")] + [InlineData("order-v7")] + [InlineData("\"v7\", \"v8\"")] + [InlineData("\"v-1\"")] + public void ParseRequired_rejects_noncanonical_values(string value) + { + var exception = Assert.Throws(() => StrongEntityTag.ParseRequired(value)); + + Assert.Equal("COMMON_BAD_REQUEST", exception.Code); + } + + [Fact] + public void ParseRequired_requires_header() + { + var exception = Assert.Throws(() => StrongEntityTag.ParseRequired(null)); + + Assert.Equal("COMMON_PRECONDITION_REQUIRED", exception.Code); + } + + [Fact] + public void EnsureMatches_reports_stale_version() + { + var exception = Assert.Throws(() => StrongEntityTag.EnsureMatches("\"v6\"", 7)); + + Assert.Equal("COMMON_CONCURRENCY_CONFLICT", exception.Code); + } +} -- Gitee From cb433e2efa6559f5454bbe9dd8cdf84fbdc4cbc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:51:56 +0800 Subject: [PATCH 06/33] fix(persistence): centralize audit timestamps --- .../Persistence/EShopDbContext.cs | 47 +++++++++++++- .../AuditableEntityTimestampsTests.cs | 61 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 backend/tests/EShop.Tests.Unit/Infrastructure/AuditableEntityTimestampsTests.cs diff --git a/backend/src/EShop.Infrastructure/Persistence/EShopDbContext.cs b/backend/src/EShop.Infrastructure/Persistence/EShopDbContext.cs index 0439d22..f5264b6 100644 --- a/backend/src/EShop.Infrastructure/Persistence/EShopDbContext.cs +++ b/backend/src/EShop.Infrastructure/Persistence/EShopDbContext.cs @@ -11,6 +11,7 @@ using EShop.Modules.Ordering.Infrastructure; using EShop.Modules.Payment.Infrastructure; using EShop.Modules.Settlement.Infrastructure; using EShop.Modules.Shop.Infrastructure; +using EShop.SharedKernel; using EShop.SharedKernel.Persistence; using Microsoft.EntityFrameworkCore; @@ -23,7 +24,51 @@ namespace EShop.Infrastructure.Persistence; /// public sealed class EShopDbContext : DbContext, IUnitOfWork { - public EShopDbContext(DbContextOptions options) : base(options) { } + private readonly TimeProvider _timeProvider; + + public EShopDbContext( + DbContextOptions options, + TimeProvider? timeProvider = null) : base(options) + { + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public override int SaveChanges() + => SaveChanges(acceptAllChangesOnSuccess: true); + + public override int SaveChanges(bool acceptAllChangesOnSuccess) + { + ApplyAuditTimestamps(); + return base.SaveChanges(acceptAllChangesOnSuccess); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + => SaveChangesAsync(acceptAllChangesOnSuccess: true, cancellationToken); + + public override Task SaveChangesAsync( + bool acceptAllChangesOnSuccess, + CancellationToken cancellationToken = default) + { + ApplyAuditTimestamps(); + return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken); + } + + private void ApplyAuditTimestamps() + { + var utcNow = _timeProvider.GetUtcNow().UtcDateTime; + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Added) + { + entry.Property(entity => entity.CreatedAt).CurrentValue = utcNow; + entry.Property(entity => entity.UpdatedAt).CurrentValue = utcNow; + } + else if (entry.State == EntityState.Modified) + { + entry.Property(entity => entity.UpdatedAt).CurrentValue = utcNow; + } + } + } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/backend/tests/EShop.Tests.Unit/Infrastructure/AuditableEntityTimestampsTests.cs b/backend/tests/EShop.Tests.Unit/Infrastructure/AuditableEntityTimestampsTests.cs new file mode 100644 index 0000000..0d16952 --- /dev/null +++ b/backend/tests/EShop.Tests.Unit/Infrastructure/AuditableEntityTimestampsTests.cs @@ -0,0 +1,61 @@ +using EShop.Infrastructure.Persistence; +using EShop.Modules.Identity.Domain; +using Microsoft.EntityFrameworkCore; + +namespace EShop.Tests.Unit.Infrastructure; + +public sealed class AuditableEntityTimestampsTests +{ + [Fact] + public async Task Save_changes_assigns_consistent_creation_and_update_timestamps() + { + var createdAt = new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero); + await using var db = CreateDbContext(createdAt); + var user = new AppUser("audit-user", "hashed-password"); + db.Set().Add(user); + + await db.SaveChangesAsync(); + + Assert.Equal(createdAt.UtcDateTime, user.CreatedAt); + Assert.Equal(createdAt.UtcDateTime, user.UpdatedAt); + } + + [Fact] + public async Task Save_changes_advances_updated_at_for_domain_mutations() + { + var createdAt = new DateTimeOffset(2030, 1, 2, 3, 4, 5, TimeSpan.Zero); + var updatedAt = createdAt.AddHours(2); + var timeProvider = new MutableTimeProvider(createdAt); + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + await using var db = new EShopDbContext(options, timeProvider); + var user = new AppUser("updated-user", "hashed-password"); + db.Set().Add(user); + await db.SaveChangesAsync(); + + timeProvider.SetUtcNow(updatedAt); + user.UpdateProfile("新的昵称", null); + await db.SaveChangesAsync(); + + Assert.Equal(createdAt.UtcDateTime, user.CreatedAt); + Assert.Equal(updatedAt.UtcDateTime, user.UpdatedAt); + } + + private static EShopDbContext CreateDbContext(DateTimeOffset utcNow) + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new EShopDbContext(options, new MutableTimeProvider(utcNow)); + } + + private sealed class MutableTimeProvider(DateTimeOffset utcNow) : TimeProvider + { + private DateTimeOffset _utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void SetUtcNow(DateTimeOffset value) => _utcNow = value; + } +} -- Gitee From 7ea8fff90c8def0e3d129e2fdecd6cb46d692a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:52:15 +0800 Subject: [PATCH 07/33] chore(dotnet): refresh locked restore targets --- backend/src/EShop.Api/packages.lock.json | 3 +-- backend/src/EShop.Contracts/packages.lock.json | 3 +-- backend/src/EShop.Infrastructure/packages.lock.json | 3 +-- backend/src/EShop.Modules.Analytics/packages.lock.json | 3 +-- backend/src/EShop.Modules.Audit/packages.lock.json | 3 +-- backend/src/EShop.Modules.Cart/packages.lock.json | 3 +-- backend/src/EShop.Modules.Catalog/packages.lock.json | 3 +-- backend/src/EShop.Modules.Coupon/packages.lock.json | 3 +-- backend/src/EShop.Modules.CustomerService/packages.lock.json | 3 +-- backend/src/EShop.Modules.Identity/packages.lock.json | 3 +-- backend/src/EShop.Modules.Ordering/packages.lock.json | 3 +-- backend/src/EShop.Modules.Payment/packages.lock.json | 3 +-- backend/src/EShop.Modules.Settlement/packages.lock.json | 3 +-- backend/src/EShop.Modules.Shop/packages.lock.json | 3 +-- backend/src/EShop.SharedKernel/packages.lock.json | 3 +-- 15 files changed, 15 insertions(+), 30 deletions(-) diff --git a/backend/src/EShop.Api/packages.lock.json b/backend/src/EShop.Api/packages.lock.json index 1c0082c..dbc83e3 100644 --- a/backend/src/EShop.Api/packages.lock.json +++ b/backend/src/EShop.Api/packages.lock.json @@ -411,7 +411,6 @@ "Npgsql": "10.0.3" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Contracts/packages.lock.json b/backend/src/EShop.Contracts/packages.lock.json index dc9a394..a45a013 100644 --- a/backend/src/EShop.Contracts/packages.lock.json +++ b/backend/src/EShop.Contracts/packages.lock.json @@ -5,7 +5,6 @@ "eshop.sharedkernel": { "type": "Project" } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Infrastructure/packages.lock.json b/backend/src/EShop.Infrastructure/packages.lock.json index 6851772..2b1bdc2 100644 --- a/backend/src/EShop.Infrastructure/packages.lock.json +++ b/backend/src/EShop.Infrastructure/packages.lock.json @@ -307,7 +307,6 @@ "Microsoft.EntityFrameworkCore": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Analytics/packages.lock.json b/backend/src/EShop.Modules.Analytics/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Analytics/packages.lock.json +++ b/backend/src/EShop.Modules.Analytics/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Audit/packages.lock.json b/backend/src/EShop.Modules.Audit/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Audit/packages.lock.json +++ b/backend/src/EShop.Modules.Audit/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Cart/packages.lock.json b/backend/src/EShop.Modules.Cart/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Cart/packages.lock.json +++ b/backend/src/EShop.Modules.Cart/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Catalog/packages.lock.json b/backend/src/EShop.Modules.Catalog/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Catalog/packages.lock.json +++ b/backend/src/EShop.Modules.Catalog/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Coupon/packages.lock.json b/backend/src/EShop.Modules.Coupon/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Coupon/packages.lock.json +++ b/backend/src/EShop.Modules.Coupon/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.CustomerService/packages.lock.json b/backend/src/EShop.Modules.CustomerService/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.CustomerService/packages.lock.json +++ b/backend/src/EShop.Modules.CustomerService/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Identity/packages.lock.json b/backend/src/EShop.Modules.Identity/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Identity/packages.lock.json +++ b/backend/src/EShop.Modules.Identity/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Ordering/packages.lock.json b/backend/src/EShop.Modules.Ordering/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Ordering/packages.lock.json +++ b/backend/src/EShop.Modules.Ordering/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Payment/packages.lock.json b/backend/src/EShop.Modules.Payment/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Payment/packages.lock.json +++ b/backend/src/EShop.Modules.Payment/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Settlement/packages.lock.json b/backend/src/EShop.Modules.Settlement/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Settlement/packages.lock.json +++ b/backend/src/EShop.Modules.Settlement/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.Modules.Shop/packages.lock.json b/backend/src/EShop.Modules.Shop/packages.lock.json index cc6e0ed..6d00671 100644 --- a/backend/src/EShop.Modules.Shop/packages.lock.json +++ b/backend/src/EShop.Modules.Shop/packages.lock.json @@ -40,7 +40,6 @@ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10" } } - }, - "net10.0/linux-x64": {} + } } } \ No newline at end of file diff --git a/backend/src/EShop.SharedKernel/packages.lock.json b/backend/src/EShop.SharedKernel/packages.lock.json index d4eccf7..6afd678 100644 --- a/backend/src/EShop.SharedKernel/packages.lock.json +++ b/backend/src/EShop.SharedKernel/packages.lock.json @@ -1,7 +1,6 @@ { "version": 2, "dependencies": { - "net10.0": {}, - "net10.0/linux-x64": {} + "net10.0": {} } } \ No newline at end of file -- Gitee From 4da2db9287b5f18090a055978209b5ec7a2f5b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:52:38 +0800 Subject: [PATCH 08/33] fix(frontend): harden authenticated transport adapters --- .../src/modules/identity/stores/authStore.ts | 2 + .../platformAdapters.contract.spec.ts | 22 +++- .../platform/__tests__/webFilePicker.spec.ts | 53 ++++++++ frontend/src/platform/auth/authRequest.ts | 2 +- frontend/src/platform/ports/platform.ts | 3 +- frontend/src/platform/web/index.ts | 120 +++++++++--------- .../src/shared/api/generatedClient.spec.ts | 50 +++++++- frontend/src/shared/api/generatedClient.ts | 27 +++- frontend/src/shared/api/index.ts | 6 +- frontend/src/shared/utils/http.spec.ts | 69 ++++++++++ frontend/src/shared/utils/http.ts | 88 ++++++++++--- 11 files changed, 348 insertions(+), 94 deletions(-) create mode 100644 frontend/src/platform/__tests__/webFilePicker.spec.ts diff --git a/frontend/src/modules/identity/stores/authStore.ts b/frontend/src/modules/identity/stores/authStore.ts index af51391..dadfb42 100644 --- a/frontend/src/modules/identity/stores/authStore.ts +++ b/frontend/src/modules/identity/stores/authStore.ts @@ -7,6 +7,7 @@ import { defineStore } from 'pinia'; import { ref, computed, onScopeDispose } from 'vue'; import type { UserProfile } from '../types'; import { setTokenProvider, setUnauthorizedHandler } from '@/shared/utils/http'; +import { setGeneratedAuthProvider } from '@/shared/api/generatedClient'; import { usePlatformServices, type AuthCredentials, @@ -485,6 +486,7 @@ export const useAuthStore = defineStore('identity/auth', () => { () => accessToken.value, () => sessionEpoch, ); + setGeneratedAuthProvider(() => accessToken.value); setUnauthorizedHandler((expectedGeneration) => expectedGeneration === sessionEpoch ? refresh() : false, ); diff --git a/frontend/src/platform/__tests__/platformAdapters.contract.spec.ts b/frontend/src/platform/__tests__/platformAdapters.contract.spec.ts index c112a46..58280fd 100644 --- a/frontend/src/platform/__tests__/platformAdapters.contract.spec.ts +++ b/frontend/src/platform/__tests__/platformAdapters.contract.spec.ts @@ -149,10 +149,12 @@ class DeferredFirstLockManager extends TestLockManager { class TestBroadcastChannel { private static readonly channels = new Map>(); + static readonly postedMessages: unknown[] = []; private readonly listeners = new Set<(event: { data: unknown }) => void>(); static reset() { this.channels.clear(); + this.postedMessages.length = 0; } constructor(private readonly name: string) { @@ -168,6 +170,7 @@ class TestBroadcastChannel { } postMessage(data: unknown) { + TestBroadcastChannel.postedMessages.push(data); for (const channel of TestBroadcastChannel.channels.get(this.name) ?? []) { if (channel !== this) { queueMicrotask(() => { @@ -306,12 +309,15 @@ describe('Web AuthTransport', () => { it('shares one refresh result across two tabs without replaying the Cookie token', async () => { let resolveLeader!: (response: Response) => void; - const fetchMock = vi.fn( - () => - new Promise((resolve) => { - resolveLeader = resolve; - }), - ); + const fetchMock = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLeader = resolve; + }), + ) + .mockResolvedValueOnce(envelope(200, webPayload)); vi.stubGlobal('fetch', fetchMock); const firstTab = createWebPlatformServices(); const secondTab = createWebPlatformServices(); @@ -328,13 +334,15 @@ describe('Web AuthTransport', () => { { ok: true, value: session }, { ok: true, value: session }, ]); - expect(fetchMock).toHaveBeenCalledOnce(); + expect(fetchMock).toHaveBeenCalledTimes(2); const persistedResult = window.localStorage.getItem(WEB_REFRESH_RESULT_KEY); expect(persistedResult).not.toBeNull(); expect(persistedResult).toContain('"state":"SUCCEEDED"'); expect(persistedResult).not.toContain('access-token'); expect(persistedResult).not.toContain('alice'); + expect(JSON.stringify(TestBroadcastChannel.postedMessages)).not.toContain('access-token'); + expect(JSON.stringify(TestBroadcastChannel.postedMessages)).not.toContain('alice'); }); it.each([ diff --git a/frontend/src/platform/__tests__/webFilePicker.spec.ts b/frontend/src/platform/__tests__/webFilePicker.spec.ts new file mode 100644 index 0000000..06e1590 --- /dev/null +++ b/frontend/src/platform/__tests__/webFilePicker.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createWebPlatformServices } from '@/platform/web'; + +describe('web file picker', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.querySelectorAll('input[type="file"]').forEach((input) => input.remove()); + }); + + it('settles as cancelled and removes its temporary input', async () => { + let pickerInput!: HTMLInputElement; + vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => { + pickerInput = document.querySelector('input[type="file"]') as HTMLInputElement; + }); + const result = createWebPlatformServices().files.pick(); + + expect(pickerInput).not.toBeNull(); + pickerInput.dispatchEvent(new Event('cancel')); + + await expect(result).resolves.toEqual({ ok: false, reason: 'CANCELLED' }); + expect(document.querySelector('input[type="file"]')).toBeNull(); + }); + + it('returns selected files exactly once', async () => { + let pickerInput!: HTMLInputElement; + vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => { + pickerInput = document.querySelector('input[type="file"]') as HTMLInputElement; + }); + const file = new File(['image'], 'cover.png', { type: 'image/png' }); + const result = createWebPlatformServices().files.pick({ multiple: true }); + Object.defineProperty(pickerInput, 'files', { configurable: true, value: [file] }); + + pickerInput.dispatchEvent(new Event('change')); + pickerInput.dispatchEvent(new Event('cancel')); + + await expect(result).resolves.toEqual({ + ok: true, + value: [{ name: 'cover.png', size: 5, type: 'image/png', file }], + }); + }); + + it('returns unavailable when the browser refuses to open the picker', async () => { + vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => { + throw new DOMException('not available', 'NotAllowedError'); + }); + + await expect(createWebPlatformServices().files.pick()).resolves.toEqual({ + ok: false, + reason: 'UNAVAILABLE', + }); + }); +}); diff --git a/frontend/src/platform/auth/authRequest.ts b/frontend/src/platform/auth/authRequest.ts index 55de025..f267028 100644 --- a/frontend/src/platform/auth/authRequest.ts +++ b/frontend/src/platform/auth/authRequest.ts @@ -113,7 +113,7 @@ export function toAuthFailure(error: unknown): AuthResult { return { ok: false, reason: 'NETWORK_UNAVAILABLE', - traceId: error.traceId, + ...(error.traceId !== 'unknown' ? { traceId: error.traceId } : {}), }; } diff --git a/frontend/src/platform/ports/platform.ts b/frontend/src/platform/ports/platform.ts index 74b2ac9..94935a8 100644 --- a/frontend/src/platform/ports/platform.ts +++ b/frontend/src/platform/ports/platform.ts @@ -5,7 +5,8 @@ export type AppTarget = 'web' | 'desktop'; export type RuntimeKind = 'web' | 'pwa' | 'desktop'; export type LifecycleState = 'active' | 'background'; -export type PlatformFailureReason = 'UNSUPPORTED' | 'DENIED' | 'INVALID_INPUT' | 'UNAVAILABLE'; +export type PlatformFailureReason = + 'UNSUPPORTED' | 'DENIED' | 'CANCELLED' | 'INVALID_INPUT' | 'UNAVAILABLE'; export type PlatformResult = | { diff --git a/frontend/src/platform/web/index.ts b/frontend/src/platform/web/index.ts index 9c40fdb..00b995d 100644 --- a/frontend/src/platform/web/index.ts +++ b/frontend/src/platform/web/index.ts @@ -31,7 +31,7 @@ const LEGACY_WEB_LOGOUT_PENDING_PREFIX = 'eshop.auth.logout-pending.v2.'; const WEB_PRINCIPAL_GENERATION_KEY = 'eshop.auth.principal-generation.v1'; const WEB_COOKIE_MUTATION_LOCK_NAME = 'eshop.auth.cookie-mutation.v1'; const WEB_REFRESH_RESULT_KEY = 'eshop.auth.refresh-result.v1'; -const WEB_REFRESH_CHANNEL_NAME = 'eshop.auth.refresh-results.v1'; +const WEB_REFRESH_CHANNEL_NAME = 'eshop.auth.refresh-results.v2'; const WEB_AUTH_LIFECYCLE_CHANNEL_NAME = 'eshop.auth.lifecycle.v1'; const AUTH_FAILURE_REASONS = new Set([ @@ -61,9 +61,8 @@ interface RefreshCoordinationRecord { } interface RefreshBroadcastMessage { - version: 1; + version: 2; record: RefreshCoordinationRecord; - session?: AuthSession; failure?: { reason: AuthFailureReason; traceId?: string; @@ -299,20 +298,12 @@ function parseRefreshBroadcast(value: unknown): RefreshBroadcastMessage | null { const candidate = value as Partial; const record = parseRefreshRecord(JSON.stringify(candidate.record)); - if (!record || candidate.version !== 1) { + if (!record || candidate.version !== 2) { return null; } if (record.state === 'SUCCEEDED') { - try { - return { - version: 1, - record, - session: toAuthSession(candidate.session as AccessTokenPayload), - }; - } catch { - return null; - } + return { version: 2, record }; } if ( @@ -321,7 +312,7 @@ function parseRefreshBroadcast(value: unknown): RefreshBroadcastMessage | null { AUTH_FAILURE_REASONS.has(candidate.failure.reason) ) { return { - version: 1, + version: 2, record, failure: { reason: candidate.failure.reason, @@ -333,7 +324,7 @@ function parseRefreshBroadcast(value: unknown): RefreshBroadcastMessage | null { } return { - version: 1, + version: 2, record, }; } @@ -538,28 +529,9 @@ function createWebRefreshCoordinator(logoutFence: LogoutFence): WebRefreshCoordi } if (recordCompletedWhileWaiting) { - const broadcast = await waitForBroadcast(currentRecord.attemptId); - if ( - broadcast?.session && - broadcast.record.state === 'SUCCEEDED' && - broadcast.record.logoutGeneration === logoutGeneration - ) { - if (signal?.aborted) { - return sessionExpired(); - } - if (logoutFence.hasPending()) { - return recover(recoverUncertainState); - } - if (!logoutFence.matchesGeneration(logoutGeneration)) { - return sessionExpired(); - } - - return { - ok: true, - value: broadcast.session, - }; - } - // 消息可能在 follower 建立频道前完成;锁内顺序轮换当前 Cookie 仍安全。 + await waitForBroadcast(currentRecord.attemptId); + // 成功广播只表示前一标签完成了轮换,不携带 access token。 + // follower 在同一 Web Lock 内用新的 HttpOnly Cookie 顺序刷新。 } } @@ -620,16 +592,16 @@ function createWebRefreshCoordinator(logoutFence: LogoutFence): WebRefreshCoordi ) => { try { channel?.postMessage({ - version: 1, + version: 2, record: currentRecord, - ...(current.ok - ? { session: current.value } - : { + ...(!current.ok + ? { failure: { reason: current.reason, ...(current.traceId ? { traceId: current.traceId } : {}), }, - }), + } + : {}), } satisfies RefreshBroadcastMessage); } catch { // follower 会在锁内使用新 Cookie 顺序刷新,不会退化为并发请求。 @@ -818,25 +790,53 @@ function createFilePickerService(): FilePickerService { input.multiple = options.multiple ?? false; input.accept = options.accept?.join(',') ?? ''; - input.addEventListener( - 'change', - () => { - const selections = Array.from(input.files ?? []).map((file) => ({ - name: file.name, - size: file.size, - type: file.type, - file, - })); - - resolve({ - ok: true, - value: selections, - }); - }, - { once: true }, - ); + input.hidden = true; + document.body.append(input); + let settled = false; + let focusTimer: ReturnType | null = null; + + const cleanup = () => { + input.removeEventListener('change', handleChange); + input.removeEventListener('cancel', handleCancel); + window.removeEventListener('focus', handleWindowFocus); + if (focusTimer) clearTimeout(focusTimer); + input.remove(); + }; + const finish = (result: Awaited>) => { + if (settled) return; + settled = true; + cleanup(); + resolve(result); + }; + const handleChange = () => { + const files = Array.from(input.files ?? []); + if (files.length === 0) { + finish({ ok: false, reason: 'CANCELLED' }); + return; + } + const selections = files.map((file) => ({ + name: file.name, + size: file.size, + type: file.type, + file, + })); + finish({ ok: true, value: selections }); + }; + const handleCancel = () => finish({ ok: false, reason: 'CANCELLED' }); + const handleWindowFocus = () => { + focusTimer = setTimeout(() => { + if (!settled && (input.files?.length ?? 0) === 0) handleCancel(); + }, 250); + }; - input.click(); + input.addEventListener('change', handleChange); + input.addEventListener('cancel', handleCancel); + window.addEventListener('focus', handleWindowFocus); + try { + input.click(); + } catch { + finish({ ok: false, reason: 'UNAVAILABLE' }); + } }), }; } diff --git a/frontend/src/shared/api/generatedClient.spec.ts b/frontend/src/shared/api/generatedClient.spec.ts index 22c6d0d..1827937 100644 --- a/frontend/src/shared/api/generatedClient.spec.ts +++ b/frontend/src/shared/api/generatedClient.spec.ts @@ -1,6 +1,15 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { client } from '@/generated/client.gen'; -import { cartGetCart, catalogListProducts, platformGetLiveness } from '@/generated/sdk.gen'; +import { + cartGetCart, + catalogListProducts, + customerServiceMarkRead, + platformGetLiveness, +} from '@/generated/sdk.gen'; +import { + configureGeneratedApiClient, + setGeneratedAuthProvider, +} from '@/shared/api/generatedClient'; describe('Generated API client', () => { it('has a string base URL (runtime origin is set by configureGeneratedApiClient)', () => { @@ -21,4 +30,41 @@ describe('Generated API client', () => { expect(typeof catalogListProducts).toBe('function'); expect(typeof platformGetLiveness).toBe('function'); }); + + it('reads the current bearer token dynamically and includes web credentials', async () => { + let token = 'token-a'; + setGeneratedAuthProvider(() => token); + configureGeneratedApiClient({ baseUrl: new URL('https://example.test') }); + const config = client.getConfig(); + + expect( + await (config.auth as (auth: { scheme: 'bearer'; type: 'http' }) => string)({ + scheme: 'bearer', + type: 'http', + }), + ).toBe('token-a'); + token = 'token-b'; + expect( + await (config.auth as (auth: { scheme: 'bearer'; type: 'http' }) => string)({ + scheme: 'bearer', + type: 'http', + }), + ).toBe('token-b'); + expect(config.credentials).toBe('include'); + }); + + it('tolerates a bodyless 204 transport response', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = input instanceof Request ? input : new Request(input, init); + expect(request.headers.get('Authorization')).toBe('Bearer token-b'); + return new Response(null, { status: 204 }); + }); + configureGeneratedApiClient({ + baseUrl: new URL('https://example.test'), + fetch: fetchMock, + }); + + await expect(customerServiceMarkRead({ path: { id: '1' } })).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledOnce(); + }); }); diff --git a/frontend/src/shared/api/generatedClient.ts b/frontend/src/shared/api/generatedClient.ts index 23bedbf..24b0845 100644 --- a/frontend/src/shared/api/generatedClient.ts +++ b/frontend/src/shared/api/generatedClient.ts @@ -15,6 +15,11 @@ interface AnyApiEnvelope { } let interceptorsInstalled = false; +let generatedAuthTokenProvider: () => string | null = () => null; + +export function setGeneratedAuthProvider(provider: () => string | null): void { + generatedAuthTokenProvider = provider; +} function isEnvelope(value: unknown): value is AnyApiEnvelope { if (typeof value !== 'object' || value === null) { @@ -70,6 +75,16 @@ export function mapGeneratedApiError(error: unknown, response?: Response): ApiCl }); } + if (response.status >= 500) { + return new ApiClientError({ + code: 'NETWORK_UNAVAILABLE', + message: '服务暂时不可用,请稍后重试。', + status: response.status, + traceId: traceIdFrom(error, response), + cause: error, + }); + } + return new ApiClientError({ code: 'CLIENT_PROTOCOL_ERROR', message: '服务端返回内容不符合 API 契约。', @@ -83,10 +98,11 @@ async function assertEnvelopeResponse(response: Response): Promise { const traceId = response.headers.get('x-trace-id') ?? undefined; const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; - if ( - response.status === 204 || - (!contentType.includes('application/json') && !contentType.includes('+json')) - ) { + if ([204, 205, 304].includes(response.status)) { + return response; + } + + if (!contentType.includes('application/json') && !contentType.includes('+json')) { throw new ApiClientError({ code: 'CLIENT_PROTOCOL_ERROR', message: '服务端返回内容不符合 API 契约。', @@ -157,6 +173,9 @@ export function configureGeneratedApiClient( generatedClient.setConfig({ baseUrl: baseUrl.href.replace(/\/$/, ''), + auth: (auth) => + auth.scheme === 'bearer' ? (generatedAuthTokenProvider() ?? undefined) : undefined, + credentials: __APP_TARGET__ === 'web' ? 'include' : 'omit', fetch: fetchImplementation ?? ((input, init) => globalThis.fetch(input, init)), throwOnError: true, }); diff --git a/frontend/src/shared/api/index.ts b/frontend/src/shared/api/index.ts index bfcda02..bdde853 100644 --- a/frontend/src/shared/api/index.ts +++ b/frontend/src/shared/api/index.ts @@ -1,3 +1,7 @@ export { resolveApiBaseUrl } from '@/shared/api/config'; -export { configureGeneratedApiClient, mapGeneratedApiError } from '@/shared/api/generatedClient'; +export { + configureGeneratedApiClient, + mapGeneratedApiError, + setGeneratedAuthProvider, +} from '@/shared/api/generatedClient'; export { ApiClientError } from '@/shared/errors/ApiClientError'; diff --git a/frontend/src/shared/utils/http.spec.ts b/frontend/src/shared/utils/http.spec.ts index 7941e9d..fc22d38 100644 --- a/frontend/src/shared/utils/http.spec.ts +++ b/frontend/src/shared/utils/http.spec.ts @@ -308,4 +308,73 @@ describe('http authentication retry', () => { const requestInit = fetchMock.mock.calls[0]?.[1]; expect(new Headers(requestInit?.headers).get('X-CSRF-Token')).toBe('1'); }); + + it('maps a rejected fetch to NetworkError without clearing authentication state', async () => { + const refresh = vi.fn(async () => true); + setUnauthorizedHandler(refresh); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('offline'))); + + await expect(http.get('/products')).rejects.toMatchObject({ + name: 'NetworkError', + httpStatus: 0, + code: 'NETWORK_UNAVAILABLE', + }); + expect(refresh).not.toHaveBeenCalled(); + }); + + it.each([ + new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }), + new Response( + JSON.stringify({ code: 'FAILED', message: 'wrong code', data: null, traceId: 'trace-wrong' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ])('rejects malformed HTTP 200 envelopes', async (response) => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => response.clone()), + ); + + await expect(http.get('/products')).rejects.toMatchObject({ + name: 'ApiError', + code: 'CLIENT_PROTOCOL_ERROR', + httpStatus: 200, + }); + }); + + it('classifies malformed 5xx bodies as service/network failures', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('gateway error', { status: 503 })), + ); + + await expect(http.get('/products')).rejects.toMatchObject({ + name: 'NetworkError', + httpStatus: 503, + }); + }); + + it('classifies malformed 4xx bodies as a stable client protocol error', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('bad request', { status: 400 })), + ); + + await expect(http.get('/products')).rejects.toMatchObject({ + name: 'ApiError', + code: 'CLIENT_PROTOCOL_ERROR', + httpStatus: 400, + }); + }); + + it('returns null data for a valid bodyless success', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(null, { status: 204 })), + ); + + await expect(http.post('/conversations/1/read')).resolves.toEqual({ + data: null, + etag: null, + }); + }); }); diff --git a/frontend/src/shared/utils/http.ts b/frontend/src/shared/utils/http.ts index b49b924..da6de6c 100644 --- a/frontend/src/shared/utils/http.ts +++ b/frontend/src/shared/utils/http.ts @@ -151,6 +151,19 @@ function parseRetryAfterSeconds(value: string | null): number | undefined { return Number.isSafeInteger(seconds) ? seconds : undefined; } +function isApiResponse(value: unknown): value is ApiResponse { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial>; + return ( + typeof candidate.code === 'string' && + candidate.code.length > 0 && + typeof candidate.message === 'string' && + typeof candidate.traceId === 'string' && + candidate.traceId.length > 0 && + Object.prototype.hasOwnProperty.call(candidate, 'data') + ); +} + export class AuthenticationContextChangedError extends ApiError { constructor() { super( @@ -216,14 +229,20 @@ async function request( headers['Content-Type'] = options.contentType ?? 'application/json'; } - const res = await fetch(url, { - method, - headers, - body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined, - signal: options.signal, - // Web refresh token 依赖 HttpOnly Cookie;桌面端只发送 Authorization/native token。 - credentials: options.credentials ?? (__APP_TARGET__ === 'web' ? 'include' : 'omit'), - }); + let res: Response; + try { + res = await fetch(url, { + method, + headers, + body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined, + signal: options.signal, + // Web refresh token 依赖 HttpOnly Cookie;桌面端只发送 Authorization/native token。 + credentials: options.credentials ?? (__APP_TARGET__ === 'web' ? 'include' : 'omit'), + }); + } catch (fetchError) { + if (fetchError instanceof DOMException && fetchError.name === 'AbortError') throw fetchError; + throw new NetworkError('无法连接服务,请检查网络后重试', 0, 'unknown'); + } if (res.ok) { // 登录主体切换后,旧主体的成功响应(包括迟到的 Set-Cookie/用户资料)不得交付。 @@ -239,27 +258,60 @@ async function request( return { data: null as T, etag: responseEtag }; } - // 解析 JSON 响应体(统一信封) - // 先取原文再 JSON.parse,是为了在 catch 时把响应前 200 字写到错误消息里: - // 下次再出现"响应解析失败 (HTTP 200)",页面/日志里就能直接看到实际是 HTML、SPA fallback 还是空 body。 - let envelope: ApiResponse; - let rawBody = ''; + // 解析并验证统一信封;绝不把 HTML、空对象或 200 错误码当作成功数据交付。 + let envelope: ApiResponse | null = null; + let rawBody: string; try { rawBody = await res.text(); - envelope = rawBody ? (JSON.parse(rawBody) as ApiResponse) : ({} as ApiResponse); + } catch (readError) { + if (readError instanceof DOMException && readError.name === 'AbortError') throw readError; + throw new NetworkError( + `响应读取中断 (HTTP ${res.status})`, + res.status, + res.headers.get('x-trace-id') ?? 'unknown', + 'NETWORK_UNAVAILABLE', + retryAfterSeconds, + ); + } + + try { + const parsed: unknown = rawBody ? JSON.parse(rawBody) : null; + if (isApiResponse(parsed)) envelope = parsed; } catch { - const preview = rawBody.trim().slice(0, 200) || ''; + // 下面按 HTTP 状态转换为稳定的协议错误,不泄露响应原文。 + } + + if (!envelope) { + if (res.status >= 500) { + throw new NetworkError( + `服务暂时不可用 (HTTP ${res.status})`, + res.status, + res.headers.get('x-trace-id') ?? 'unknown', + 'NETWORK_UNAVAILABLE', + retryAfterSeconds, + ); + } throw new ApiError( - 'PARSE_ERROR', - `响应解析失败 (HTTP ${res.status},body: ${preview})`, + 'CLIENT_PROTOCOL_ERROR', + `服务端返回内容不符合 API 契约 (HTTP ${res.status})`, res.status, - 'unknown', + res.headers.get('x-trace-id') ?? 'unknown', responseEtag ?? undefined, + retryAfterSeconds, ); } // 成功 if (res.ok) { + if (envelope.code !== 'OK') { + throw new ApiError( + 'CLIENT_PROTOCOL_ERROR', + '服务端成功响应包含非 OK 业务码。', + res.status, + envelope.traceId, + responseEtag ?? undefined, + ); + } // JSON 解析期间也可能发生显式 login/logout,再次核对后才交付数据。 assertAuthenticatedRequestContext(requestAccessToken, requestSessionGeneration); return { data: envelope.data, etag: responseEtag }; -- Gitee From 9b92d97ef61970a76515c624c6934556b6023ade Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Tue, 11 Aug 2026 13:52:45 +0800 Subject: [PATCH 09/33] fix(frontend): harden showroom renderer lifecycle --- .../c16/components/C16WebGpuCanvas.spec.ts | 7 +- .../c16/components/C16WebGpuCanvas.vue | 7 +- .../catalog/c16/engine/C16WebGpuEngine.ts | 66 +++++++++++++++--- .../catalog/c16/pages/C16WebGpuPage.vue | 4 +- .../components/ShowroomCanvas.spec.ts | 22 ++++++ .../showroom/components/ShowroomCanvas.vue | 35 ++++++++-- .../showroom/pages/CatalogShowroomPage.vue | 4 +- .../showroom/pages/ShowroomLandingPage.vue | 4 +- .../su7/components/Su7WebGpuCanvas.spec.ts | 13 +++- .../su7/components/Su7WebGpuCanvas.vue | 6 +- .../su7/engine/Su7WebGpuEngine.spec.ts | 8 ++- .../catalog/su7/engine/Su7WebGpuEngine.ts | 68 +++++++++++++++---- .../catalog/su7/pages/Su7WebGpuPage.spec.ts | 20 +++++- .../catalog/su7/pages/Su7WebGpuPage.vue | 17 +++-- 14 files changed, 230 insertions(+), 51 deletions(-) diff --git a/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.spec.ts b/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.spec.ts index 9673e5c..f639caf 100644 --- a/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.spec.ts +++ b/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.spec.ts @@ -14,6 +14,10 @@ const engineModule = vi.hoisted(() => ({ })); const platformMocks = vi.hoisted(() => ({ + lifecycle: { + current: vi.fn(() => 'active'), + subscribe: vi.fn(() => vi.fn()), + }, renderer: { currentUrl: () => new URL(globalThis.location.href), dispatchPointerUp: vi.fn(), @@ -35,7 +39,7 @@ vi.mock('../engine', () => ({ })); vi.mock('@/platform', () => ({ - usePlatformServices: () => ({ renderer: platformMocks.renderer }), + usePlatformServices: () => platformMocks, })); const defaultProps = { @@ -159,6 +163,7 @@ describe('C16WebGpuCanvas', () => { forceWebGL: false, initialView: 'side', isMobile: false, + lifecycle: platformMocks.lifecycle, renderer: expect.any(Object), }); diff --git a/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.vue b/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.vue index 23c0812..02f524a 100644 --- a/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.vue +++ b/frontend/src/modules/catalog/c16/components/C16WebGpuCanvas.vue @@ -22,7 +22,8 @@ const emit = defineEmits<{ ready: []; }>(); -const renderer = usePlatformServices().renderer; +const platform = usePlatformServices(); +const renderer = platform.renderer; const canvas = ref(null); const engine = shallowRef(); let active = true; @@ -43,6 +44,9 @@ function isLive(): boolean { function reportFailure(message: string): void { if (!isLive()) return; terminalFailure = true; + engine.value?.dispose(); + engine.value = undefined; + lastEnginePressed = undefined; emit('error', message); } @@ -150,6 +154,7 @@ onMounted(async () => { forceWebGL: props.forceWebGl, initialView: props.view, isMobile: props.isMobile, + lifecycle: platform.lifecycle, onBackend: (backend) => { if (isLive()) emit('backend', backend); }, diff --git a/frontend/src/modules/catalog/c16/engine/C16WebGpuEngine.ts b/frontend/src/modules/catalog/c16/engine/C16WebGpuEngine.ts index 2fd86dc..0d9d1ae 100644 --- a/frontend/src/modules/catalog/c16/engine/C16WebGpuEngine.ts +++ b/frontend/src/modules/catalog/c16/engine/C16WebGpuEngine.ts @@ -217,7 +217,7 @@ import { type C16TransitionTrack, } from './transitionTrack'; import { bindC16SharedReflectorTexture } from './sharedReflector'; -import type { RendererPlatformService } from '@/platform'; +import type { AppLifecycleService, RendererPlatformService } from '@/platform'; export type C16RendererBackend = 'webgpu' | 'webgl2'; export type { C16CameraView } from './sceneState'; @@ -227,6 +227,7 @@ export interface C16WebGpuEngineOptions { forceWebGL?: boolean; initialView?: C16CameraView; isMobile?: boolean; + lifecycle: AppLifecycleService; onBackend?: (backend: C16RendererBackend) => void; onError?: (message: string) => void; onProgress?: (progress: number, label: string) => void; @@ -779,6 +780,9 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { private driveTargetVelocity = 0; private driveAcceleration = 1.5; private disposed = false; + private animationReady = false; + private terminalFailure = false; + private unsubscribeLifecycle?: () => void; private rendererInitialized = false; private width = 0; private height = 0; @@ -795,6 +799,7 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { forceWebGL: boolean, isMobile: boolean, private readonly rendererPlatform: RendererPlatformService, + private readonly lifecycle: AppLifecycleService, onError?: (message: string) => void, ) { this.reportError = onError; @@ -829,14 +834,13 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { forceWebGL, }); this.renderer.onDeviceLost = (info) => { - if (this.disposed) return; - const failure = new Error( + this.failRendering( info.message?.trim() ? `图形设备连接已中断:${info.message}` : '图形设备连接已中断,请刷新页面后重试。', ); - this.reportError?.(failure.message); }; + this.unsubscribeLifecycle = this.lifecycle.subscribe(this.handleLifecycleChange); this.renderer.outputColorSpace = SRGBColorSpace; this.renderer.sortObjects = false; this.renderer.toneMapping = LinearToneMapping; @@ -880,6 +884,7 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { options.forceWebGL ?? false, options.isMobile ?? false, options.renderer, + options.lifecycle, options.onError, ); @@ -897,7 +902,8 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { await reportC16StartupProgress(options, 0.92, '正在准备实时着色管线'); await engine.warmScenePipelines(); await reportC16StartupProgress(options, 1, '展厅核心资源已就绪'); - engine.renderer.setAnimationLoop(engine.renderFrame); + engine.animationReady = true; + engine.syncAnimationLoop(); return engine; } catch (reason) { engine.dispose(); @@ -1055,6 +1061,10 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { dispose(): void { if (this.disposed) return; this.disposed = true; + this.terminalFailure = true; + this.animationReady = false; + this.unsubscribeLifecycle?.(); + this.unsubscribeLifecycle = undefined; if (this.rendererInitialized) this.renderer.setAnimationLoop(null); this.controls.dispose(); this.floorReflector?.dispose(); @@ -1363,6 +1373,42 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { } } + private readonly handleLifecycleChange = (): void => { + if (this.disposed || this.terminalFailure) return; + if (this.lifecycle.current() === 'active') this.resetAnimationClocks(); + this.syncAnimationLoop(); + }; + + private resetAnimationClocks(): void { + this.fixedFrameClock = createC16FixedFrameClockState(); + this.performanceMonitor = createC16LegacyPerformanceMonitorState(); + this.tweenWallClock = createC16WallClockState(); + this.bodyColorTweenClock = createC16SceneTweenClockState(); + this.interiorColorTweenClock = createC16SceneTweenClockState(); + for (const channel of C16_OWNED_TRANSITION_CHANNELS) { + this.sceneTweenClocks.set(channel, createC16SceneTweenClockState()); + } + } + + private syncAnimationLoop(): void { + const shouldRender = + this.animationReady && + !this.disposed && + !this.terminalFailure && + this.lifecycle.current() === 'active'; + if (this.rendererInitialized) { + this.renderer.setAnimationLoop(shouldRender ? this.renderFrame : null); + } + } + + private failRendering(message: string): void { + if (this.disposed || this.terminalFailure) return; + this.terminalFailure = true; + this.animationReady = false; + if (this.rendererInitialized) this.renderer.setAnimationLoop(null); + this.reportError?.(message); + } + private prepareHomeStage( stage: Object3D, floorLight: Texture, @@ -3780,7 +3826,7 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { } private readonly renderFrame = (timestamp: number): void => { - if (this.disposed) return; + if (this.disposed || this.terminalFailure || this.lifecycle.current() !== 'active') return; if (this.introPhase === 'playing') { this.introPrerollCalibration = observeC16IntroPrerollFrameRate( this.introPrerollCalibration, @@ -3859,13 +3905,11 @@ class ThreeC16WebGpuEngine implements C16WebGpuEngine { this.camera.updateMatrixWorld(); } } catch (reason) { - this.renderer.setAnimationLoop(null); - const failure = new Error( + const failureMessage = reason instanceof Error && reason.message.trim() ? `实时画面渲染失败:${reason.message}` - : '实时画面渲染失败,请切换到旧版展厅。', - ); - this.reportError?.(failure.message); + : '实时画面渲染失败,请切换到旧版展厅。'; + this.failRendering(failureMessage); return; } }; diff --git a/frontend/src/modules/catalog/c16/pages/C16WebGpuPage.vue b/frontend/src/modules/catalog/c16/pages/C16WebGpuPage.vue index 0ad7e3d..30cf814 100644 --- a/frontend/src/modules/catalog/c16/pages/C16WebGpuPage.vue +++ b/frontend/src/modules/catalog/c16/pages/C16WebGpuPage.vue @@ -326,7 +326,7 @@ onBeforeUnmount(() => { diff --git a/frontend/src/modules/catalog/su7/pages/createSu7Poster.spec.ts b/frontend/src/modules/catalog/su7/pages/su7ShareValue.spec.ts similarity index 40% rename from frontend/src/modules/catalog/su7/pages/createSu7Poster.spec.ts rename to frontend/src/modules/catalog/su7/pages/su7ShareValue.spec.ts index 60035c9..f380fd2 100644 --- a/frontend/src/modules/catalog/su7/pages/createSu7Poster.spec.ts +++ b/frontend/src/modules/catalog/su7/pages/su7ShareValue.spec.ts @@ -1,38 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { createSu7ShareUrl, parseSu7ShareValue } from './createSu7Poster'; - -const shareBase = 'https://eshop.example/showroom/su7-webgpu?renderer=webgpu#paint'; - -describe('createSu7ShareUrl', () => { - it('encodes authored preset identifiers', () => { - expect( - createSu7ShareUrl( - { - customPaint: { color: '#000000', metalness: 0, roughness: 0 }, - paintId: '11', - }, - shareBase, - ), - ).toBe('https://eshop.example/showroom/su7-webgpu?v=h11'); - }); - - it('encodes custom HSL paint, roughness, and metalness in the original order', () => { - expect( - createSu7ShareUrl( - { - customPaint: { - color: 'hsl(40.31, 100%, 62.35%)', - metalness: 0.1, - roughness: 0.03, - }, - paintId: 'custom', - }, - shareBase, - ), - ).toBe('https://eshop.example/showroom/su7-webgpu?v=ffc03f081a'); - }); +import { parseSu7ShareValue } from './su7ShareValue'; +describe('parseSu7ShareValue', () => { it('restores shared presets and custom paint payloads', () => { expect(parseSu7ShareValue('h10')).toEqual({ paintId: '10' }); @@ -43,18 +13,9 @@ describe('createSu7ShareUrl', () => { expect(custom?.lightness).toBeCloseTo(62.35, 2); expect(custom?.roughness).toBeCloseTo(8 / 255, 8); expect(custom?.metalness).toBeCloseTo(26 / 255, 8); - expect( - createSu7ShareUrl( - { - customPaint: custom!.customPaint!, - paintId: custom!.paintId, - }, - shareBase, - ), - ).toBe('https://eshop.example/showroom/su7-webgpu?v=ffc03f081a'); }); - it('keeps the authored defaults for zero roughness and metalness share bytes', () => { + it('keeps the legacy defaults for zero roughness and metalness bytes', () => { expect(parseSu7ShareValue('ffc03f0000')).toMatchObject({ metalness: 0.1, roughness: 0.03, diff --git a/frontend/src/modules/catalog/su7/pages/createSu7Poster.ts b/frontend/src/modules/catalog/su7/pages/su7ShareValue.ts similarity index 30% rename from frontend/src/modules/catalog/su7/pages/createSu7Poster.ts rename to frontend/src/modules/catalog/su7/pages/su7ShareValue.ts index bb75a1c..bf4f932 100644 --- a/frontend/src/modules/catalog/su7/pages/createSu7Poster.ts +++ b/frontend/src/modules/catalog/su7/pages/su7ShareValue.ts @@ -1,14 +1,5 @@ -import QRCode from 'qrcode'; - import type { Su7CustomPaint } from '../engine'; -const ICON_ROOT = '/su7/1.0.5/icon/'; - -interface Su7PosterOptions { - customPaint: Su7CustomPaint; - paintId: string; -} - export interface Su7ShareSelection { customPaint?: Su7CustomPaint; hue?: number; @@ -19,62 +10,6 @@ export interface Su7ShareSelection { saturation?: number; } -function loadImage(source: string): Promise { - return new Promise((resolve, reject) => { - const image = new globalThis.Image(); - image.decoding = 'async'; - image.addEventListener('load', () => resolve(image), { once: true }); - image.addEventListener('error', () => reject(new Error('SU7 海报资源加载失败。')), { - once: true, - }); - image.src = source; - }); -} - -function hueToRgb(first: number, second: number, hue: number): number { - const normalized = ((hue % 1) + 1) % 1; - if (normalized < 1 / 6) return first + (second - first) * 6 * normalized; - if (normalized < 1 / 2) return second; - if (normalized < 2 / 3) return first + (second - first) * (2 / 3 - normalized) * 6; - return first; -} - -function hslToHex(source: string): string { - const match = source.match( - /^hsl\(\s*([+-]?(?:\d+\.?\d*|\.\d+))\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))%\s*,\s*([+-]?(?:\d+\.?\d*|\.\d+))%\s*\)$/i, - ); - if (!match) return 'ffc03f'; - const hue = Number(match[1]) / 360; - const saturation = Math.min(Math.max(Number(match[2]) / 100, 0), 1); - const lightness = Math.min(Math.max(Number(match[3]) / 100, 0), 1); - const second = - lightness < 0.5 - ? lightness * (1 + saturation) - : lightness + saturation - lightness * saturation; - const first = 2 * lightness - second; - const channels = - saturation === 0 - ? [lightness, lightness, lightness] - : [ - hueToRgb(first, second, hue + 1 / 3), - hueToRgb(first, second, hue), - hueToRgb(first, second, hue - 1 / 3), - ]; - return channels - .map((channel) => - Math.round(channel * 255) - .toString(16) - .padStart(2, '0'), - ) - .join(''); -} - -function scalarToHex(value: number): string { - return Math.round(Math.min(Math.max(value, 0), 1) * 255) - .toString(16) - .padStart(2, '0'); -} - function rgbToHsl( red: number, green: number, @@ -121,7 +56,7 @@ export function parseSu7ShareValue( const blue = Number.parseInt(value.slice(4, 6), 16); const roughnessByte = Number.parseInt(value.slice(6, 8), 16); const metalnessByte = Number.parseInt(value.slice(8, 10), 16); - // The source only overwrites these defaults when the parsed byte is truthy. + // Preserve compatibility with links emitted before the poster feature was removed. const roughness = roughnessByte === 0 ? 0.03 : roughnessByte / 255; const metalness = metalnessByte === 0 ? 0.1 : metalnessByte / 255; const hsl = rgbToHsl(red, green, blue); @@ -135,85 +70,3 @@ export function parseSu7ShareValue( roughness, }; } - -export function createSu7ShareUrl(options: Su7PosterOptions, baseUrl: string): string { - const payload = - options.paintId !== 'custom' - ? `h${options.paintId}` - : `${hslToHex(options.customPaint.color)}${scalarToHex( - options.customPaint.roughness, - )}${scalarToHex(options.customPaint.metalness)}`; - const shareUrl = new URL(baseUrl); - shareUrl.hash = ''; - shareUrl.search = ''; - shareUrl.searchParams.set('v', payload); - return shareUrl.toString(); -} - -export async function createSu7Poster( - source: string, - options: Su7PosterOptions, - baseUrl: string, -): Promise { - const scene = await loadImage(source); - const canvas = globalThis.document.createElement('canvas'); - canvas.width = scene.naturalWidth; - canvas.height = scene.naturalHeight; - const context = canvas.getContext('2d'); - if (!context) throw new Error('当前浏览器无法生成 SU7 海报。'); - - const width = canvas.width; - const height = canvas.height; - const scale = width / 1_920; - context.drawImage(scene, 0, 0, width, height); - - const qrDataUrl = await QRCode.toDataURL(createSu7ShareUrl(options, baseUrl), { - color: { dark: '#000000ff', light: '#ffffffff' }, - errorCorrectionLevel: 'H', - margin: 0, - width: 256, - }); - const [su7Wordmark, xiaomiWordmark, gameMcu, qrFrame, qrCode] = await Promise.all([ - loadImage(`${ICON_ROOT}su7_custom_wordmark.svg`), - loadImage(`${ICON_ROOT}xiaomi_custom_wordmark.svg`), - loadImage(`${ICON_ROOT}gamemcu.webp`), - loadImage(`${ICON_ROOT}qrcode.webp`), - loadImage(qrDataUrl), - ]); - - const drawCentered = ( - image: CanvasImageSource, - centerX: number, - centerY: number, - drawWidth: number, - drawHeight: number, - ) => { - context.drawImage( - image, - centerX - drawWidth / 2, - centerY - drawHeight / 2, - drawWidth, - drawHeight, - ); - }; - - drawCentered(su7Wordmark, width * 0.2, height * 0.2, 500 * scale, ((500 * 228) / 718) * scale); - drawCentered( - xiaomiWordmark, - width * 0.77, - height * 0.85, - 550 * scale, - ((550 * 118) / 729) * scale, - ); - drawCentered(gameMcu, width * 0.13, height * 0.85, 130 * scale, ((130 * 53) / 187) * scale); - drawCentered( - qrFrame, - width * 0.85, - height * 0.2 + 23 * scale, - 160 * scale, - ((160 * 330) / 256) * scale, - ); - drawCentered(qrCode, width * 0.85, height * 0.2, 150 * scale, 150 * scale); - - return canvas.toDataURL('image/png'); -} diff --git a/frontend/src/modules/ordering/pages/FinancialDisplayContract.spec.ts b/frontend/src/modules/ordering/pages/FinancialDisplayContract.spec.ts index 77d6fe1..d5f515a 100644 --- a/frontend/src/modules/ordering/pages/FinancialDisplayContract.spec.ts +++ b/frontend/src/modules/ordering/pages/FinancialDisplayContract.spec.ts @@ -1,3 +1,5 @@ +/// + import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; diff --git a/frontend/src/shared/utils/resourceUrl.spec.ts b/frontend/src/shared/utils/resourceUrl.spec.ts index 4cd8559..09dc169 100644 --- a/frontend/src/shared/utils/resourceUrl.spec.ts +++ b/frontend/src/shared/utils/resourceUrl.spec.ts @@ -56,7 +56,7 @@ describe('resolveApiResourceUrl', () => { }); describe('resolveShowroomAssetUrl', () => { - it.each(['/c16/release/model.webp', '/su7/1.0.5/icon/photo.webp'])( + it.each(['/c16/release/model.webp', '/su7/1.0.5/icon/xiaomi_su7.webp'])( 'accepts a protected showroom asset path: %s', (resource) => { expect(resolveShowroomAssetUrl(resource)).toBe(resource); diff --git a/frontend/tests/e2e/showroom.spec.ts b/frontend/tests/e2e/showroom.spec.ts index 7464453..93c72fa 100644 --- a/frontend/tests/e2e/showroom.spec.ts +++ b/frontend/tests/e2e/showroom.spec.ts @@ -84,7 +84,7 @@ test('C16 保留不可点击标识并返回实际上一页', async ({ page }) => await expect(page).toHaveURL(/\/showroom\/handheld$/); }); -test('SU7 不加载音乐并返回实际上一页', async ({ page }) => { +test('SU7 不加载音乐、不提供拍摄海报并返回实际上一页', async ({ page }) => { test.setTimeout(60_000); const audioRequests: string[] = []; page.on('request', (request) => { @@ -96,6 +96,10 @@ test('SU7 不加载音乐并返回实际上一页', async ({ page }) => { await expect(page).toHaveURL(/\/showroom\/su7-webgpu$/); await expect(page.locator('audio')).toHaveCount(0); + await expect(page.locator('.camera-control')).toHaveCount(0); + await expect(page.locator('.screenshot-overlay')).toHaveCount(0); + await expect(page.locator('.photo-flash')).toHaveCount(0); + await expect(page.getByRole('button', { name: /拍摄 SU7 定制海报/ })).toHaveCount(0); await expect(page.getByRole('button', { name: '返回到商城' })).toBeVisible(); expect(audioRequests).toEqual([]); await page.getByRole('button', { name: '返回到商城' }).click(); -- Gitee From 7c5783a23dfe7e45617242cd47c664d1c2584db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 21:33:07 +0800 Subject: [PATCH 24/33] fix(catalog): render product overview without clipping --- .../catalog/pages/ProductDetailPage.spec.ts | 48 +++ .../catalog/pages/ProductDetailPage.vue | 390 ++++++++++++++++-- frontend/tests/e2e/mobile-storefront.spec.ts | 6 +- frontend/vite.config.ts | 6 +- 4 files changed, 405 insertions(+), 45 deletions(-) diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts index b534ea8..51115f2 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts @@ -161,6 +161,54 @@ describe('ProductDetailPage.vue', () => { }); }); + it('按 _galleryCount 拆分轮播与图文,不展示右侧关键参数与 meta', async () => { + const base = await mockGetProduct('100'); + mockGetProduct.mockClear(); + const images = Array.from({ length: 12 }, (_, i) => ({ + id: `img-${i}`, + url: `/uploads/products/${i}.webp`, + isCover: i === 0, + sortOrder: i, + })); + mockGetProduct.mockResolvedValueOnce({ + data: { + ...base.data, + description: '55dB 深度降噪', + specificationsJson: JSON.stringify({ + 卖点: '55dB 深度降噪 | 智能查找', + 颜色: '冰釉白', + 降噪: '55dB', + 来源: '小米商城 product_id=22137', + 概述页: 'https://www.mi.com/prod/demo', + _galleryCount: '9', + 单耳重量: '5.3g', + }), + images, + }, + }); + + const router = await makeRouter('/products/100'); + const wrapper = mount(ProductDetailPage, { global: { plugins: [router] } }); + await flushPromises(); + + expect(wrapper.findAll('.thumbnail')).toHaveLength(9); + expect(wrapper.findAll('.overview-image')).toHaveLength(3); + expect(wrapper.find('.overview-stack').exists()).toBe(true); + expect(wrapper.find('iframe').exists()).toBe(false); + expect(wrapper.text()).toContain('55dB 深度降噪 | 智能查找'); + expect(wrapper.text()).not.toContain('关键参数'); + expect(wrapper.text()).not.toContain('小米商城 product_id'); + expect(wrapper.text()).not.toContain('_galleryCount'); + + const specsTab = wrapper.findAll('.tab').find((tab) => tab.text().includes('规格参数')); + expect(specsTab).toBeTruthy(); + await specsTab!.trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('单耳重量'); + expect(wrapper.text()).toContain('5.3g'); + expect(wrapper.text()).not.toContain('概述页'); + }); + it('keeps tab panels addressable and supports roving keyboard focus', async () => { const router = await makeRouter('/products/100'); const host = globalThis.document.createElement('div'); diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.vue b/frontend/src/modules/catalog/pages/ProductDetailPage.vue index 510db2d..7943f05 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.vue +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.vue @@ -45,6 +45,22 @@ const lightboxCloseButton = ref(null); let lightboxReturnFocus: HTMLElement | null = null; let loadRequestId = 0; +/** + * 图片顺序约定(与 seed 一致):gallery 轮播在前,overview 图文长图在后。 + * 张数以规格 meta `_galleryCount` 为准(爬取时 gallery/* 计数写入),不猜前 N 张。 + * 无 meta 时全部当轮播,避免把商品图误塞进图文区。 + */ +/** 种子写入的溯源/布局 meta,不在 C 端规格表展示 */ +const HIDDEN_SPEC_KEYS = new Set([ + '来源', + '概述页', + '参数页', + 'source', + 'overviewUrl', + 'specsUrl', + '_galleryCount', +]); + const sortedImages = computed(() => { const imgs = (product.value?.images ?? []).filter( (image) => !failedImageIds.value.has(image.id) && resolveApiResourceUrl(image.url) !== null, @@ -52,10 +68,54 @@ const sortedImages = computed(() => { imgs.sort((a, b) => (a.isCover !== b.isCover ? (a.isCover ? -1 : 1) : a.sortOrder - b.sortOrder)); return imgs; }); + +const specs = computed>(() => { + try { + if (!product.value?.specificationsJson) return {}; + const parsed: unknown = JSON.parse(product.value.specificationsJson); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const result: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (value == null) continue; + const text = String(value).trim(); + if (!text) continue; + result[key] = text; + } + return result; + } catch { + return {}; + } +}); + +/** 轮播张数:优先 `_galleryCount`(种子从 gallery/* 写入) */ +const galleryCount = computed(() => { + const all = sortedImages.value.length; + if (all === 0) return 0; + const raw = specs.value['_galleryCount']?.trim(); + const parsed = raw ? Number(raw) : NaN; + if (Number.isInteger(parsed) && parsed > 0) { + return Math.min(parsed, all); + } + // 无标记时不猜:全部作为轮播 + return all; +}); + +/** 左侧主图轮播:gallery */ +const galleryImages = computed(() => sortedImages.value.slice(0, galleryCount.value)); + +/** + * 图文详情始终使用 API 已托管的 overview 图片。 + * 图片进入普通文档流后会按固有比例自然撑高,避免第三方 iframe 的固定高度、 + * 一次性测量或跨域脚本异常裁掉后续内容。 + */ +const detailImages = computed(() => sortedImages.value.slice(galleryCount.value)); + const currentImage = computed(() => { - if (sortedImages.value.length === 0) return null; + if (galleryImages.value.length === 0) return null; return ( - sortedImages.value.find((i) => i.id === selectedImageId.value) ?? sortedImages.value[0] ?? null + galleryImages.value.find((i) => i.id === selectedImageId.value) ?? + galleryImages.value[0] ?? + null ); }); const currentImageUrl = computed(() => @@ -72,7 +132,7 @@ function resolveReviewImageUrls(images: readonly string[]): string[] { function markProductImageFailed(imageId: string) { failedImageIds.value = new Set([...failedImageIds.value, imageId]); if (selectedImageId.value === imageId) selectedImageId.value = null; - if (lightboxOpen.value && sortedImages.value.length === 0) void closeLightbox(); + if (lightboxOpen.value && galleryImages.value.length === 0) void closeLightbox(); } function markProductImageSourceFailed(failedSource: string) { @@ -82,14 +142,23 @@ function markProductImageSourceFailed(failedSource: string) { if (failedImage) markProductImageFailed(failedImage.id); } -const specs = computed>(() => { - try { - return product.value?.specificationsJson ? JSON.parse(product.value.specificationsJson) : {}; - } catch { - return {}; - } +/** 卖点:优先规格里的「卖点」,否则用 description */ +const sellingPoint = computed(() => { + const fromSpec = specs.value['卖点']?.trim(); + if (fromSpec) return fromSpec; + return product.value?.description?.trim() || ''; }); -const specEntries = computed(() => Object.entries(specs.value)); + +/** 完整规格表:去掉 meta 与卖点(卖点单独展示) */ +const specEntries = computed(() => + Object.entries(specs.value).filter( + ([key, value]) => + !HIDDEN_SPEC_KEYS.has(key) && + !key.startsWith('_') && + key !== '卖点' && + !/^https?:\/\//i.test(value), + ), +); const stockOk = computed( () => (product.value?.inStock ?? false) && (product.value?.stock ?? 0) > 0, @@ -267,9 +336,11 @@ function hideBrokenReviewImage(event: Event) { // ============================================================ // 评价 // ============================================================ -const activeTab = ref<'detail' | 'reviews'>('detail'); +const activeTab = ref<'detail' | 'specs' | 'reviews'>('detail'); const detailTabButton = ref(null); +const specsTabButton = ref(null); const reviewsTabButton = ref(null); + const reviewsData = ref(null); const reviewsLoading = ref(false); const reviewsError = ref(''); @@ -302,18 +373,28 @@ async function loadReviews() { } } -function switchTab(tab: 'detail' | 'reviews') { +function switchTab(tab: 'detail' | 'specs' | 'reviews') { activeTab.value = tab; if (tab === 'reviews' && !reviewsData.value) { void loadReviews(); } } -async function moveTabFocus(event: KeyboardEvent, current: 'detail' | 'reviews') { - const tabOrder = ['detail', 'reviews'] as const; +function focusTabButton(tab: 'detail' | 'specs' | 'reviews') { + if (tab === 'detail') detailTabButton.value?.focus(); + else if (tab === 'specs') specsTabButton.value?.focus(); + else reviewsTabButton.value?.focus(); +} + +async function moveTabFocus(event: KeyboardEvent, current: 'detail' | 'specs' | 'reviews') { + const tabOrder = ( + specEntries.value.length > 0 + ? (['detail', 'specs', 'reviews'] as const) + : (['detail', 'reviews'] as const) + ) as readonly ('detail' | 'specs' | 'reviews')[]; let target: (typeof tabOrder)[number] | null = null; - if (event.key === 'Home') target = tabOrder[0]; + if (event.key === 'Home') target = tabOrder[0]!; else if (event.key === 'End') target = tabOrder[tabOrder.length - 1]!; else if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { target = tabOrder[(tabOrder.indexOf(current) + 1) % tabOrder.length]!; @@ -325,7 +406,7 @@ async function moveTabFocus(event: KeyboardEvent, current: 'detail' | 'reviews') event.preventDefault(); switchTab(target); await nextTick(); - (target === 'detail' ? detailTabButton.value : reviewsTabButton.value)?.focus(); + focusTabButton(target); } function goReviewsPage(page: number) { @@ -426,9 +507,9 @@ watch( @error="markProductImageSourceFailed" /> -
+
+ +
+
+ +
+
+

规格参数

+
+
+
{{ k }}
+
{{ v }}
+
+
+
@@ -848,9 +984,15 @@ watch( font-size: 28px; font-weight: 700; color: #1a1a1a; - margin: 0 0 12px; + margin: 0 0 8px; line-height: 1.3; } +.selling-point { + margin: 0 0 14px; + font-size: 14px; + line-height: 1.55; + color: #666; +} .meta { display: flex; @@ -903,12 +1045,15 @@ watch( margin: 0 0 32px; } -/* 规格参数 */ +/* 购买区精简参数 */ .specs-card { background: #f5f5f5; border-radius: var(--radius-sm); padding: 12px 16px; - margin-bottom: 32px; + margin-bottom: 20px; +} +.specs-card--compact { + margin-bottom: 24px; } .section-label { font-size: var(--text-xs); @@ -930,12 +1075,26 @@ watch( } .spec-k { color: #888; - min-width: 52px; + min-width: 64px; + flex-shrink: 0; } .spec-v { color: #1a1a1a; font-weight: 600; } +.specs-more { + margin-top: 10px; + padding: 0; + border: none; + background: none; + color: #409eff; + font-size: 13px; + cursor: pointer; + text-align: left; +} +.specs-more:hover { + text-decoration: underline; +} /* 库存 */ .stock { @@ -1012,11 +1171,48 @@ watch( gap: 12px; } -/* ========== 详情 ========== */ +/* ========== 图文详情 ========== */ +.detail-tab, +.specs-tab { + padding-top: 20px; +} +.overview-section { + margin: 0; +} +.overview-stack { + display: flex; + flex-direction: column; + align-items: stretch; + width: 100%; + margin: 0; + background: transparent; + border-radius: 0; + overflow: visible; + border: none; + gap: 0; +} +.overview-image { + display: block; + width: 100%; + height: auto; + vertical-align: top; + background: transparent; + border-radius: 0; +} +.detail-empty { + text-align: center; + padding: 40px 16px; + color: #999; + font-size: 14px; +} +.detail-specs-jump { + display: flex; + justify-content: center; + padding: 20px 0 8px; +} .desc-section { - margin-top: 40px; - padding-top: 28px; - border-top: 1px solid #eee; + margin-top: 8px; + padding-top: 8px; } .desc-title { display: flex; @@ -1045,6 +1241,67 @@ watch( white-space: pre-wrap; } +/* ========== 完整规格表 ========== */ +.specs-full-section { + width: 100%; + max-width: none; +} +.specs-table { + width: 100%; + display: flex; + flex-direction: column; + border: 1px solid #e8e8e8; + border-radius: var(--radius-md); + overflow: hidden; + background: #fff; + box-sizing: border-box; +} +.specs-row { + display: grid; + grid-template-columns: minmax(120px, 28%) minmax(0, 1fr); + align-items: stretch; + width: 100%; + border-bottom: 1px solid #eee; + box-sizing: border-box; +} +.specs-row:last-child { + border-bottom: none; +} +.specs-row:nth-child(even) { + background: #fafafa; +} +.specs-row-k, +.specs-row-v { + display: flex; + align-items: center; + min-width: 0; + padding: 12px 16px; + font-size: 13px; + line-height: 1.5; + box-sizing: border-box; +} +.specs-row-k { + color: #888; + background: #f5f5f5; + border-right: 1px solid #eee; + font-weight: 500; +} +.specs-row-v { + color: #1a1a1a; + font-weight: 500; + word-break: break-word; +} +@media (max-width: 640px) { + .specs-row { + grid-template-columns: minmax(96px, 34%) minmax(0, 1fr); + } + .specs-row-k, + .specs-row-v { + padding: 10px 12px; + font-size: 12px; + } +} + /* ========== Lightbox ========== */ .lightbox-overlay { position: fixed; @@ -1420,7 +1677,7 @@ watch( } .title { - margin-bottom: 15px; + margin-bottom: 10px; color: var(--text); font-size: clamp(30px, 2.6vw, 42px); font-weight: 760; @@ -1428,6 +1685,10 @@ watch( line-height: 1.12; } +.selling-point { + color: var(--text-muted); +} + .meta { margin-bottom: 30px; } @@ -1498,6 +1759,51 @@ watch( color: var(--text); } +.specs-more { + color: var(--brand); +} + +.overview-stack { + background: var(--surface); + border-color: var(--border); +} + +.overview-image { + background: var(--surface-soft); +} + +.detail-empty { + color: var(--text-muted); +} + +.specs-full-section { + width: 100%; +} + +.specs-table { + width: 100%; + border-color: var(--border); + background: var(--surface-elevated); +} + +.specs-row { + border-bottom-color: var(--border); +} + +.specs-row:nth-child(even) { + background: var(--surface-soft); +} + +.specs-row-k { + color: var(--text-muted); + background: color-mix(in srgb, var(--surface-muted) 55%, transparent); + border-right-color: var(--border); +} + +.specs-row-v { + color: var(--text); +} + .stock { color: var(--success); } diff --git a/frontend/tests/e2e/mobile-storefront.spec.ts b/frontend/tests/e2e/mobile-storefront.spec.ts index b8bdc64..022d92c 100644 --- a/frontend/tests/e2e/mobile-storefront.spec.ts +++ b/frontend/tests/e2e/mobile-storefront.spec.ts @@ -329,7 +329,9 @@ for (const viewport of [ await expect(page.getByText(PRODUCTS[0]!.name)).toBeVisible(); await expectNoDocumentOverflow(page); - const filterPanel = page.getByRole('form', { name: '筛选商品' }); + const filterPanel = page.getByRole(viewport.width <= 760 ? 'dialog' : 'form', { + name: '筛选商品', + }); if (viewport.width <= 760) { const filterButton = page.getByRole('button', { name: /^筛选/ }); await expect(filterButton).toBeVisible(); @@ -360,7 +362,7 @@ test('320px 窄屏详情、评价与优惠券不会横向溢出', async ({ page await page.goto('/products/mobile-product'); await expect(page.getByRole('heading', { level: 1, name: PRODUCTS[0]!.name })).toBeVisible(); await expectNoDocumentOverflow(page); - await page.getByRole('button', { name: '评价', exact: true }).click(); + await page.getByRole('tab', { name: /^评价/ }).click(); await expect(page.getByText(/用户 999999/)).toBeVisible(); await expectNoDocumentOverflow(page); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 24c12db..e9e2b1d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -107,7 +107,8 @@ export default defineConfig(({ mode }) => { if (isWeb) { plugins.push( VitePWA({ - registerType: 'prompt', + // autoUpdate:新版本 SW 安装后立即跳过等待并接管页面,避免长期卡在旧 precache + registerType: 'autoUpdate', injectRegister: 'auto', includeAssets: ['icons/app-icon.svg'], manifest: { @@ -136,6 +137,9 @@ export default defineConfig(({ mode }) => { ], }, workbox: { + cleanupOutdatedCaches: true, + clientsClaim: true, + skipWaiting: true, navigateFallbackDenylist: [ new RegExp(`^${escapedWebApiProxyPath}(?:/|$)`), /^\/api\//, -- Gitee From add30d96fadd016453a84dc3479d714a338eee6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 21:39:18 +0800 Subject: [PATCH 25/33] fix(catalog): restore mobile detail grid --- frontend/src/modules/catalog/pages/ProductDetailPage.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.vue b/frontend/src/modules/catalog/pages/ProductDetailPage.vue index 7943f05..b48d73c 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.vue +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.vue @@ -2051,6 +2051,7 @@ watch( } .main { + display: grid; grid-template-columns: 1fr; } -- Gitee From 7e30f186d39c56a0449879b38f2985ac71a8474a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 21:42:59 +0800 Subject: [PATCH 26/33] fix(catalog): constrain mobile product media --- frontend/src/modules/catalog/pages/ProductDetailPage.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.vue b/frontend/src/modules/catalog/pages/ProductDetailPage.vue index b48d73c..6d5b0c6 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.vue +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.vue @@ -2055,6 +2055,11 @@ watch( grid-template-columns: 1fr; } + .gallery { + width: 100%; + max-width: 100%; + } + .related-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } -- Gitee From e133d0813dc16368b467a42c98fcedf5e441ec72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 21:45:21 +0800 Subject: [PATCH 27/33] fix(catalog): allow mobile detail grid to shrink --- frontend/src/modules/catalog/pages/ProductDetailPage.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.vue b/frontend/src/modules/catalog/pages/ProductDetailPage.vue index 6d5b0c6..6d1ba47 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.vue +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.vue @@ -2052,7 +2052,7 @@ watch( .main { display: grid; - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } .gallery { -- Gitee From b6cfdde8f25629aaa73a1058993708d9d693eef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 23:34:06 +0800 Subject: [PATCH 28/33] fix(catalog): embed interactive product overview safely --- .../Seeding/CatalogSeedMaterialLoader.cs | 15 + .../Modules/Catalog/CatalogDataSeederTests.cs | 2 + ...50\347\275\262\350\257\264\346\230\216.md" | 10 +- .../catalog/pages/ProductDetailPage.spec.ts | 142 ++++- .../catalog/pages/ProductDetailPage.vue | 548 +++++++++++++++--- scripts/release/eshop-http-redirect.conf | 4 + scripts/release/eshop-mi-overview-bridge.js | 267 +++++++++ scripts/release/eshop-path-locations.conf | 106 ++++ 8 files changed, 1003 insertions(+), 91 deletions(-) create mode 100644 scripts/release/eshop-http-redirect.conf create mode 100644 scripts/release/eshop-mi-overview-bridge.js diff --git a/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs index f286f4f..a42c533 100644 --- a/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs +++ b/backend/src/EShop.Modules.Catalog/Application/Seeding/CatalogSeedMaterialLoader.cs @@ -51,6 +51,7 @@ internal static class CatalogSeedMaterialLoader public Dictionary? Specifications { get; set; } public List? ParameterList { get; set; } public List? ImageFiles { get; set; } + public int? GalleryCount { get; set; } public string? CoverFile { get; set; } [JsonIgnore] @@ -183,6 +184,13 @@ internal static class CatalogSeedMaterialLoader throw new InvalidOperationException( $"商品 {product.Sku} 图片数量 {product.ImageFiles.Count} 超过上限 {MaxImagesPerProduct}。"); } + + if (product.GalleryCount is { } galleryCount + && (galleryCount < 1 || galleryCount > (product.ImageFiles?.Count ?? 0))) + { + throw new InvalidOperationException( + $"商品 {product.Sku} galleryCount 与 imageFiles 不一致:{galleryCount}/{product.ImageFiles?.Count ?? 0}。"); + } } if (requireReviewedArtifact) @@ -675,6 +683,13 @@ internal static class CatalogSeedMaterialLoader internal static string SpecsToJson(MiProduct mi) { var dict = new Dictionary(StringComparer.Ordinal); + if (mi.GalleryCount is > 0) + { + // 前端按该稳定边界把 imageFiles 的 gallery 前缀与 overview 后缀分开。 + // 放在字典首项,规格超长时的尾部裁剪不得删除布局契约。 + dict["_galleryCount"] = mi.GalleryCount.Value.ToString( + System.Globalization.CultureInfo.InvariantCulture); + } if (mi.ParameterList is { Count: > 0 }) { foreach (var row in mi.ParameterList) diff --git a/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs b/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs index 91bd3a7..e420b5c 100644 --- a/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs +++ b/backend/tests/EShop.Tests.Unit/Modules/Catalog/CatalogDataSeederTests.cs @@ -338,6 +338,7 @@ public sealed class CatalogDataSeederTests ], "specifications": { "版本": "12GB+256GB", "颜色": "黑色" }, "imageFiles": [ "00-cover.png", "01-gallery.png", "02-detail.png" ], + "galleryCount": 2, "coverFile": "00-cover.png" }, { @@ -371,6 +372,7 @@ public sealed class CatalogDataSeederTests Assert.Equal("REDMI", note.Brand); Assert.Contains("CPU型号", note.SpecificationsJson); Assert.Contains("9000mAh", note.SpecificationsJson); + Assert.Contains("\"_galleryCount\":\"2\"", note.SpecificationsJson); Assert.True(await db.Set().AnyAsync(p => p.Sku == "SKU-PHONE-099")); diff --git "a/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" "b/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" index 3844505..d17909b 100644 --- "a/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" +++ "b/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" @@ -1,7 +1,7 @@ # E-Shop 服务器部署说明 > 状态:当前验收环境的权威部署手册 -> 最后修订:2026-08-11 +> 最后修订:2026-08-12 > 原则:远端 `develop` 的确切提交、服务器 `RELEASE_INFO` 与实际符号链接必须指向同一版本;仓库不保存任何密码、Token、私钥或数据库连接串。 ## 1. 部署目标与边界 @@ -31,7 +31,9 @@ | ASP.NET Core HOME | `/opt/eshop/data/home` | | 运行环境文件 | `/etc/eshop/eshop.env` | | systemd 服务 | `eshop-api.service` | -| Nginx 路由片段 | 仓库 `scripts/release/eshop-path-locations.conf`,安装到 `/etc/nginx/snippets/eshop-path-locations.conf` | +| Nginx HTTPS 路由 | 仓库 `scripts/release/eshop-path-locations.conf`,安装到 `/etc/nginx/snippets/eshop-path-locations.conf` | +| Nginx HTTP 跳转 | 仓库 `scripts/release/eshop-http-redirect.conf`,安装到 `/etc/nginx/snippets/eshop-http-redirect.conf` | +| 小米概述页 bridge | 仓库 `scripts/release/eshop-mi-overview-bridge.js`,安装到 `/var/www/eshop-mi-bridge/bridge.js` | | 数据库备份 | `/opt/eshop/backups` | | 发布暂存 | `/opt/eshop-staging/` | @@ -181,7 +183,7 @@ HAVING COUNT(*) > 1; 命令会在打开写事务前逐文件验证制品;若数据库提交返回未知状态,会保留新文件并要求人工核对,禁止自动删除。成功后记录分类、商品、`product_image` 与 uploads 文件的前后计数及摘要。 7. Migration 与 Seed 均通过后,用临时链接加 `mv -T` 原子替换 `/opt/eshop/current`、`/var/www/eshop-path` 和 `/var/www/eshop-path-root/eshop`;记录切换前的三个真实目标。 -8. 将仓库受控的 `scripts/release/eshop-path-locations.conf` 安装为服务器路由片段,再运行 `sudo nginx -t`。只有文件 SHA-256 与发布产物记录一致且语法检查通过,才执行 `sudo systemctl reload nginx`。 +8. 将仓库受控的 `scripts/release/eshop-path-locations.conf` 与 `scripts/release/eshop-http-redirect.conf` 安装为服务器 HTTPS/HTTP 路由片段,并将 `scripts/release/eshop-mi-overview-bridge.js` 安装为 `/var/www/eshop-mi-bridge/bridge.js`;三者都必须核对 SHA-256、属主和只读权限,再运行 `sudo nginx -t`。只有语法检查通过,才执行 `sudo systemctl reload nginx`。bridge 只通过带会话令牌的 `postMessage` 同步纯商品概述 SPA 的父子滚动与内容高度;iframe 不授予 `allow-same-origin`,第三方脚本不能读取商城父 DOM、Cookie 或本地存储。禁止重新引入冻结任意 `div/section` 高度、一次性 `height-final` 或整站 `/prod/...` iframe。 9. 执行 `sudo systemctl restart eshop-api.service`,确认服务稳定处于 `active`,且没有 restart loop。 服务器环境文件必须把 `ASPNETCORE_URLS` 绑定为 `http://127.0.0.1:5280`。发布前后都应以 `ss`/健康检查验证该 loopback 监听;不得在文档、日志或提交中回显同一环境文件内的 Secret。 @@ -194,6 +196,7 @@ Nginx 必须保留以下语义: - `/eshop-api/api/v1/...` 去掉 `/eshop-api` 后转发到 `127.0.0.1:5280/api/v1/...`。 - `/eshop-api/health`、`/eshop-api/health/ready`、`/eshop-api/openapi/...` 和 `/eshop-api/uploads/...` 分别转发到后端同名根路径。 - 旧 `/eshop-api/v1/...` 与 `/eshop-uploads/...` 可在一个回滚窗口内保留,但新构建不得依赖旧别名。 +- `/eshop-mi-overview//` 只反代 mishop 的产品 `#app` SPA,资源经 `/eshop-mi-mishop/` 加载并注入受控 bridge;资源代理必须验证上游 TLS、移除 Cookie/Authorization/Referer,并为 sandbox 中的 ES module 返回 CORS 许可。HTTP 同名路径只做 308 HTTPS 跳转。不得恢复包含商城顶栏、导航或法律页脚的旧 `/eshop-mi-prod/` 整站反代。 ## 6. 服务器验收 @@ -208,6 +211,7 @@ Nginx 必须保留以下语义: 7. `settlement.idempotency_request` 及其 identity sequence 均由 `eshop_migrator` 持有;`eshop_app` 没有角色继承,对 `settlement` schema 只有 `USAGE`,对表只有运行所需 DML,对 sequence 只有 `USAGE, SELECT`;使用 `SET LOCAL ROLE eshop_app` 的只读权限探针不再返回 `permission denied`。 8. Catalog Seed 的制品摘要、前后行数和 uploads 文件摘要一致;已知旧基线正常补齐后目标为 43 个分类、85 个商品、约 1204 条商品图片,任何偏差都必须解释且不能用进程退出 0 代替数据验收。 9. 新版本的 `RELEASE_INFO` 至少记录 `release`、完整 `source_head`、构建时间、三类产物 SHA-256、迁移/Seed 结果、数据库与 uploads 备份、旧/新符号链接目标和验收命令结果;不得记录 Secret。 +10. 对含官方概述 URL 的商品做真实浏览器验收:iframe DOM 只能包含产品根与全部 floor,不得出现小米商城顶栏/法律页脚;sandbox 中 `location.origin` 必须是 `null`,且不能读取 `window.parent.document`;父页面滚动必须连续驱动子页面 `scrollY`、`data-msa` 动画和懒加载,子页原始交互引起的滚动也必须回传父页,滚到底后全部 floor 可达、iframe 无独立滚动条,规格表使用官方字段顺序且桌面/移动均无横向溢出。 ## 7. 回滚 diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts index 51115f2..965c1e1 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts @@ -161,7 +161,7 @@ describe('ProductDetailPage.vue', () => { }); }); - it('按 _galleryCount 拆分轮播与图文,不展示右侧关键参数与 meta', async () => { + it('官方概述使用纯内容交互 iframe,不展示截图、右侧关键参数与 meta', async () => { const base = await mockGetProduct('100'); mockGetProduct.mockClear(); const images = Array.from({ length: 12 }, (_, i) => ({ @@ -192,9 +192,18 @@ describe('ProductDetailPage.vue', () => { await flushPromises(); expect(wrapper.findAll('.thumbnail')).toHaveLength(9); - expect(wrapper.findAll('.overview-image')).toHaveLength(3); - expect(wrapper.find('.overview-stack').exists()).toBe(true); - expect(wrapper.find('iframe').exists()).toBe(false); + expect(wrapper.findAll('.overview-image')).toHaveLength(0); + expect(wrapper.find('.overview-stack').exists()).toBe(false); + const frame = wrapper.get('iframe.overview-embed'); + expect(frame.attributes('src')).toMatch( + /^\/eshop-mi-overview\/demo\/\?eshop_embed=[^&]+&eshop_parent=/, + ); + expect(frame.attributes('sandbox')).not.toContain('allow-popups'); + expect(frame.attributes('sandbox')).not.toContain('allow-same-origin'); + expect(productDetailPageSource).toContain('event.source !== frameWindow'); + expect(productDetailPageSource).toContain("event.origin !== 'null'"); + expect(productDetailPageSource).toContain('data.token !== overviewSessionToken.value'); + expect(wrapper.text()).toContain('55dB 深度降噪 | 智能查找'); expect(wrapper.text()).not.toContain('关键参数'); expect(wrapper.text()).not.toContain('小米商城 product_id'); @@ -207,6 +216,131 @@ describe('ProductDetailPage.vue', () => { expect(wrapper.text()).toContain('单耳重量'); expect(wrapper.text()).toContain('5.3g'); expect(wrapper.text()).not.toContain('概述页'); + wrapper.unmount(); + }); + + it('轮播图片失败时保持 gallery 与 overview 原始分区', async () => { + const base = await mockGetProduct('100'); + mockGetProduct.mockClear(); + const images = Array.from({ length: 12 }, (_, i) => ({ + id: `partition-${i}`, + url: `/uploads/products/partition-${i}.webp`, + isCover: i === 0, + sortOrder: i, + })); + mockGetProduct.mockResolvedValueOnce({ + data: { + ...base.data, + specificationsJson: JSON.stringify({ _galleryCount: '9', 颜色: '黑色' }), + images, + }, + }); + + const router = await makeRouter('/products/100'); + const wrapper = mount(ProductDetailPage, { global: { plugins: [router] } }); + await flushPromises(); + + await wrapper.findAll('.thumbnail')[1]!.find('img').trigger('error'); + await flushPromises(); + + const thumbnailSources = wrapper + .findAll('.thumbnail img') + .map((image) => image.attributes('src')); + const detailSources = wrapper + .findAll('.overview-image') + .map((image) => image.attributes('src')); + expect(thumbnailSources).toHaveLength(8); + expect(thumbnailSources).not.toContain('/uploads/products/partition-9.webp'); + expect(detailSources.map((source) => new URL(source!).pathname)).toEqual([ + '/uploads/products/partition-9.webp', + '/uploads/products/partition-10.webp', + '/uploads/products/partition-11.webp', + ]); + wrapper.unmount(); + }); + + it('没有官方概述页时按 _galleryCount 在普通文档流渲染详情图', async () => { + const base = await mockGetProduct('100'); + mockGetProduct.mockClear(); + const images = Array.from({ length: 12 }, (_, i) => ({ + id: `fallback-${i}`, + url: `/uploads/products/fallback-${i}.webp`, + isCover: i === 0, + sortOrder: i, + })); + mockGetProduct.mockResolvedValueOnce({ + data: { + ...base.data, + specificationsJson: JSON.stringify({ _galleryCount: '9', 颜色: '黑色' }), + images, + }, + }); + + const router = await makeRouter('/products/100'); + const wrapper = mount(ProductDetailPage, { global: { plugins: [router] } }); + await flushPromises(); + + expect(wrapper.find('iframe').exists()).toBe(false); + expect(wrapper.findAll('.thumbnail')).toHaveLength(9); + expect(wrapper.findAll('.overview-image')).toHaveLength(3); + }); + + it('REDMI Buds 8 Pro 规格按官方参数页 19 项全宽顺序展示', async () => { + const base = await mockGetProduct('100'); + mockGetProduct.mockClear(); + const officialSpecs = { + 产品形态: '入耳式耳机', + 产品颜色: '玄悟黑、冰釉白、薄雾蓝', + 频率响应范围: '20-40kHz', + 单耳重量: '5.3g(±0.1g)', + 单耳尺寸: '30.6*21.29*24.48mm', + 整机重量: '47g(±1g)', + 蓝牙版电池容量: '54mAh', + 充电接口: 'Type-C', + 耳机输入参数: '5V 250mA MAX', + 充电盒输入参数: '5V 2A MAX', + 充电盒输出参数: '5V 500mA MAX', + 降噪关短续航: '8.5h', + 降噪关长续航: '35h', + 降噪开短续航: '5h', + 降噪开长续航: '20h', + 蓝牙版连接: '支持', + 蓝牙版本: '蓝牙 5.4', + 蓝牙编解码: 'SBC / AAC / LHDC / MIHC / LC3', + 工作距离: '10m', + }; + mockGetProduct.mockResolvedValueOnce({ + data: { + ...base.data, + specificationsJson: JSON.stringify({ + ...officialSpecs, + 通用: '通用', + 颜色: '冰釉白 / 玄悟黑 / 薄雾蓝', + 降噪: '55dB', + 概述页: 'https://www.mi.com/prod/redmi-buds-8-pro', + 参数页: 'https://www.mi.com/prod/redmi-buds-8-pro/specs', + }), + }, + }); + + const router = await makeRouter('/products/100'); + const wrapper = mount(ProductDetailPage, { global: { plugins: [router] } }); + await flushPromises(); + + const specsTab = wrapper.get('#product-specs-tab'); + expect(specsTab.text()).toContain('(19)'); + await specsTab.trigger('click'); + await flushPromises(); + + const rows = wrapper.findAll('.specs-row'); + expect(rows).toHaveLength(19); + expect(rows[0]!.text()).toContain('产品形态'); + expect(rows[18]!.text()).toContain('工作距离'); + expect(wrapper.get('.specs-table').classes()).toContain('specs-table'); + expect(wrapper.text()).not.toContain('通用'); + expect(wrapper.text()).not.toContain('55dB'); + expect(wrapper.text()).not.toContain('冰釉白 / 玄悟黑 / 薄雾蓝'); + wrapper.unmount(); }); it('keeps tab panels addressable and supports roving keyboard focus', async () => { diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.vue b/frontend/src/modules/catalog/pages/ProductDetailPage.vue index 6d1ba47..82f27d4 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.vue +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.vue @@ -3,9 +3,10 @@ 路径: /products/:productId --> '; + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "frame-ancestors 'self'; form-action 'none'" always; +} + +location = /eshop-mi-overview { + return 404; +} + +location /eshop-mi-overview/ { + return 404; +} + location = /eshop-api/health { proxy_pass http://127.0.0.1:5280/health; proxy_set_header Host $host; @@ -107,6 +200,18 @@ location ^~ /eshop/assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; } +location = /eshop/registerSW.js { + root /var/www/eshop-path-root; + try_files $uri =404; + add_header Cache-Control "no-cache, no-store, must-revalidate"; +} + +location ~* ^/eshop/workbox-.*\.js$ { + root /var/www/eshop-path-root; + try_files $uri =404; + add_header Cache-Control "no-cache, no-store, must-revalidate"; +} + location = /eshop/sw.js { root /var/www/eshop-path-root; try_files $uri =404; @@ -116,6 +221,7 @@ location = /eshop/sw.js { location = /eshop/manifest.webmanifest { root /var/www/eshop-path-root; try_files $uri =404; + default_type application/manifest+json; add_header Cache-Control "no-cache, no-store, must-revalidate"; } -- Gitee From 328ab99e697bdce5264c03718245292f7efdde9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 23:36:34 +0800 Subject: [PATCH 29/33] fix(deploy): pin overview parent origin --- ...51\203\250\347\275\262\350\257\264\346\230\216.md" | 2 +- scripts/release/eshop-mi-overview-bridge.js | 11 ++++++++++- scripts/release/eshop-path-locations.conf | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git "a/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" "b/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" index d17909b..acda8ac 100644 --- "a/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" +++ "b/docs/02-\350\256\276\350\256\241\346\226\207\346\241\243/\351\203\250\347\275\262\350\257\264\346\230\216.md" @@ -183,7 +183,7 @@ HAVING COUNT(*) > 1; 命令会在打开写事务前逐文件验证制品;若数据库提交返回未知状态,会保留新文件并要求人工核对,禁止自动删除。成功后记录分类、商品、`product_image` 与 uploads 文件的前后计数及摘要。 7. Migration 与 Seed 均通过后,用临时链接加 `mv -T` 原子替换 `/opt/eshop/current`、`/var/www/eshop-path` 和 `/var/www/eshop-path-root/eshop`;记录切换前的三个真实目标。 -8. 将仓库受控的 `scripts/release/eshop-path-locations.conf` 与 `scripts/release/eshop-http-redirect.conf` 安装为服务器 HTTPS/HTTP 路由片段,并将 `scripts/release/eshop-mi-overview-bridge.js` 安装为 `/var/www/eshop-mi-bridge/bridge.js`;三者都必须核对 SHA-256、属主和只读权限,再运行 `sudo nginx -t`。只有语法检查通过,才执行 `sudo systemctl reload nginx`。bridge 只通过带会话令牌的 `postMessage` 同步纯商品概述 SPA 的父子滚动与内容高度;iframe 不授予 `allow-same-origin`,第三方脚本不能读取商城父 DOM、Cookie 或本地存储。禁止重新引入冻结任意 `div/section` 高度、一次性 `height-final` 或整站 `/prod/...` iframe。 +8. 将仓库受控的 `scripts/release/eshop-path-locations.conf` 与 `scripts/release/eshop-http-redirect.conf` 安装为服务器 HTTPS/HTTP 路由片段,并将 `scripts/release/eshop-mi-overview-bridge.js` 安装为 `/var/www/eshop-mi-bridge/bridge.js`;三者都必须核对 SHA-256、属主和只读权限,再运行 `sudo nginx -t`。只有语法检查通过,才执行 `sudo systemctl reload nginx`。HTTPS 路由注入的 `__ESHOP_MI_PARENT_ORIGIN__` 必须与正式商城 origin 完全一致;bridge 同时校验该固定白名单、父窗口和会话令牌,只同步纯商品概述 SPA 的父子滚动与内容高度。iframe 不授予 `allow-same-origin`,第三方脚本不能读取商城父 DOM、Cookie 或本地存储。禁止重新引入冻结任意 `div/section` 高度、一次性 `height-final` 或整站 `/prod/...` iframe。 9. 执行 `sudo systemctl restart eshop-api.service`,确认服务稳定处于 `active`,且没有 restart loop。 服务器环境文件必须把 `ASPNETCORE_URLS` 绑定为 `http://127.0.0.1:5280`。发布前后都应以 `ss`/健康检查验证该 loopback 监听;不得在文档、日志或提交中回显同一环境文件内的 Secret。 diff --git a/scripts/release/eshop-mi-overview-bridge.js b/scripts/release/eshop-mi-overview-bridge.js index 1bf4616..f5b35d4 100644 --- a/scripts/release/eshop-mi-overview-bridge.js +++ b/scripts/release/eshop-mi-overview-bridge.js @@ -6,6 +6,9 @@ const CHILD_SOURCE = "eshop-mi-overview"; const PARENT_SOURCE = "eshop-mi-overview-parent"; + const configuredParentOrigin = String( + window.__ESHOP_MI_PARENT_ORIGIN__ ?? "", + ); const query = new URLSearchParams(window.location.search); let parentOrigin = query.get("eshop_parent") ?? ""; try { @@ -29,7 +32,13 @@ let followupTimer = 0; // 没有可信会话令牌时保持原始页面不变,不接管任何输入或滚动。 - if (!TOKEN_RE.test(token) || !/^https:\/\//i.test(parentOrigin)) return; + if ( + !TOKEN_RE.test(token) || + !/^https:\/\//i.test(parentOrigin) || + parentOrigin !== configuredParentOrigin + ) { + return; + } function createMemoryStorage() { const entries = new Map(); diff --git a/scripts/release/eshop-path-locations.conf b/scripts/release/eshop-path-locations.conf index aec94f7..237fb50 100644 --- a/scripts/release/eshop-path-locations.conf +++ b/scripts/release/eshop-path-locations.conf @@ -82,7 +82,7 @@ location ~ "^/eshop-mi-overview/(?[A-Za-z0-9](?:[A-Za-z0-9-]{0,12 proxy_buffering on; sub_filter_once off; sub_filter 'https://mishop-pc.cnbj1.mi-fds.com/mishop-pc/production/mishop_pc/' '/eshop-mi-mishop/'; - sub_filter '' ''; + sub_filter '' ''; add_header Cache-Control "no-cache, no-store, must-revalidate" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "origin" always; -- Gitee From 0c4970f31dcd8f674e0cac20abe809b6f426be42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 23:49:45 +0800 Subject: [PATCH 30/33] fix(catalog): fill desktop overview viewport --- .../src/modules/catalog/pages/ProductDetailPage.spec.ts | 9 +++++++++ frontend/src/modules/catalog/pages/ProductDetailPage.vue | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts index 965c1e1..5fe3d92 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts @@ -134,6 +134,15 @@ describe('ProductDetailPage.vue', () => { expect(productDetailPageSource).not.toContain('min-height: calc(100vh - 72px)'); }); + it('交互概述视口按真实 sticky 顶部填满桌面可视区,不保留固定 900px 裁切带', () => { + expect(productDetailPageSource).toMatch( + /\.overview-embed\s*\{[^}]*height:\s*calc\(100dvh - var\(--overview-sticky-top, 72px\)\)/s, + ); + expect(productDetailPageSource).not.toContain( + 'height: min(900px, calc(100dvh - 88px))', + ); + }); + it('图片灯箱使用主题表面与文字色而不是固定浅色', () => { expect(productDetailPageSource).toMatch( /\.lightbox-image\s*\{[^}]*background:\s*var\(--surface-elevated\)/s, diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.vue b/frontend/src/modules/catalog/pages/ProductDetailPage.vue index 82f27d4..b9e5bf2 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.vue +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.vue @@ -1496,7 +1496,9 @@ watch( top: var(--overview-sticky-top, 72px); display: block; width: 100%; - height: min(900px, calc(100dvh - 88px)); + /* The sticky viewport must end exactly at the browser viewport bottom. A fixed 900px cap left + an exposed black strip on tall desktop screens and visibly cut the current upstream floor. */ + height: calc(100dvh - var(--overview-sticky-top, 72px)); min-height: 480px; border: 0; background: #000; @@ -1511,7 +1513,7 @@ watch( inset: 0 0 auto; display: flex; width: 100%; - height: min(900px, calc(100dvh - 88px)); + height: calc(100dvh - var(--overview-sticky-top, 72px)); min-height: 480px; align-items: center; justify-content: center; @@ -1673,11 +1675,9 @@ watch( } @media (max-width: 760px) { .overview-embed { - height: calc(100dvh - 140px); min-height: 360px; } .overview-embed-status { - height: calc(100dvh - 140px); min-height: 360px; } } -- Gitee From 7e838c758ed21abe2c3c097b48bb4ace4c8e0143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E4=BD=B3=E9=9F=B3?= <11099947+Lin-Joy@user.noreply.gitee.com> Date: Wed, 12 Aug 2026 23:56:22 +0800 Subject: [PATCH 31/33] fix(catalog): keep overview above mobile nav --- .../src/modules/catalog/pages/ProductDetailPage.spec.ts | 3 +++ frontend/src/modules/catalog/pages/ProductDetailPage.vue | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts index 5fe3d92..fcac2dc 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.spec.ts @@ -141,6 +141,9 @@ describe('ProductDetailPage.vue', () => { expect(productDetailPageSource).not.toContain( 'height: min(900px, calc(100dvh - 88px))', ); + expect(productDetailPageSource).toMatch( + /@media \(max-width: 760px\)[\s\S]*height:\s*calc\([\s\S]*100dvh - var\(--overview-sticky-top, 72px\) - 62px - env\(safe-area-inset-bottom\)[\s\S]*\)/, + ); }); it('图片灯箱使用主题表面与文字色而不是固定浅色', () => { diff --git a/frontend/src/modules/catalog/pages/ProductDetailPage.vue b/frontend/src/modules/catalog/pages/ProductDetailPage.vue index b9e5bf2..4772ec8 100644 --- a/frontend/src/modules/catalog/pages/ProductDetailPage.vue +++ b/frontend/src/modules/catalog/pages/ProductDetailPage.vue @@ -1674,10 +1674,12 @@ watch( word-break: break-word; } @media (max-width: 760px) { - .overview-embed { - min-height: 360px; - } + .overview-embed, .overview-embed-status { + /* Keep the interactive viewport above the fixed commerce navigation. */ + height: calc( + 100dvh - var(--overview-sticky-top, 72px) - 62px - env(safe-area-inset-bottom) + ); min-height: 360px; } } -- Gitee From c4e2b444977f1a2037f19743b30b7271de8fa2f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=99=93=E5=8F=B6?= Date: Thu, 13 Aug 2026 12:58:43 +0800 Subject: [PATCH 32/33] fix(catalog): normalize inline etag to quoted If-Match header --- frontend/src/shared/utils/http.spec.ts | 17 +++++++++++++++++ frontend/src/shared/utils/http.ts | 6 ++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/frontend/src/shared/utils/http.spec.ts b/frontend/src/shared/utils/http.spec.ts index fc22d38..5fdc016 100644 --- a/frontend/src/shared/utils/http.spec.ts +++ b/frontend/src/shared/utils/http.spec.ts @@ -309,6 +309,23 @@ describe('http authentication retry', () => { expect(new Headers(requestInit?.headers).get('X-CSRF-Token')).toBe('1'); }); + it.each([ + ['v3', '"v3"'], + ['"v1"', '"v1"'], + ])('normalizes the If-Match header to a quoted strong ETag (%s)', async (input, expected) => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return envelope(200, null); + }); + vi.stubGlobal('fetch', fetchMock); + + await http.post('/merchant/products/35/status', { status: 'active' }, { etag: input }); + + const requestInit = fetchMock.mock.calls[0]?.[1]; + expect(new Headers(requestInit?.headers).get('If-Match')).toBe(expected); + }); + it('maps a rejected fetch to NetworkError without clearing authentication state', async () => { const refresh = vi.fn(async () => true); setUnauthorizedHandler(refresh); diff --git a/frontend/src/shared/utils/http.ts b/frontend/src/shared/utils/http.ts index da6de6c..d8d8565 100644 --- a/frontend/src/shared/utils/http.ts +++ b/frontend/src/shared/utils/http.ts @@ -209,9 +209,11 @@ async function request( headers['Authorization'] = `Bearer ${requestAccessToken}`; } - // ETag + // ETag:响应头 ETag 已带引号("vN"),行内 etag 字段不带引号(vN)。 + // 后端 StrongEntityTag.ParseRequired 要求强 ETag(带引号),因此统一规范化。 if (options.etag) { - headers['If-Match'] = options.etag; + const etag = options.etag.trim(); + headers['If-Match'] = etag.startsWith('"') && etag.endsWith('"') ? etag : `"${etag}"`; } // Idempotency-Key -- Gitee From a0303a11796318aa57ae1ea62da7a7f415d44928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E6=99=93=E5=8F=B6?= Date: Thu, 13 Aug 2026 14:20:44 +0800 Subject: [PATCH 33/33] =?UTF-8?q?feat(payment):=20=E5=BE=85=E6=94=AF?= =?UTF-8?q?=E4=BB=98=E8=AE=A2=E5=8D=95=E5=80=92=E8=AE=A1=E6=97=B6=E4=B8=8E?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=BD=AE=E8=AF=A2=E7=A1=AE=E8=AE=A4=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ordering/pages/OrderDetailPage.vue | 23 +++ .../modules/payment/pages/PaymentPage.spec.ts | 8 + .../src/modules/payment/pages/PaymentPage.vue | 39 +++++ .../components/OrderPaymentCountdown.vue | 161 ++++++++++++++++++ .../useOrderTimeoutPolling.spec.ts | 77 +++++++++ .../composables/useOrderTimeoutPolling.ts | 81 +++++++++ 6 files changed, 389 insertions(+) create mode 100644 frontend/src/shared/components/OrderPaymentCountdown.vue create mode 100644 frontend/src/shared/composables/useOrderTimeoutPolling.spec.ts create mode 100644 frontend/src/shared/composables/useOrderTimeoutPolling.ts diff --git a/frontend/src/modules/ordering/pages/OrderDetailPage.vue b/frontend/src/modules/ordering/pages/OrderDetailPage.vue index 2a67d81..2646f9d 100644 --- a/frontend/src/modules/ordering/pages/OrderDetailPage.vue +++ b/frontend/src/modules/ordering/pages/OrderDetailPage.vue @@ -12,9 +12,11 @@ import { v4 as uuidv4 } from 'uuid'; import { useCartStore } from '../../cart/stores/cartStore'; import ConfirmDialog from '@/shared/components/ConfirmDialog.vue'; import OrderStatusBadge from '../components/OrderStatusBadge.vue'; +import OrderPaymentCountdown from '@/shared/components/OrderPaymentCountdown.vue'; import OrderItemList from '../components/OrderItemList.vue'; import { useAuthStore } from '../../identity/stores/authStore'; import { useToast } from '@/shared/composables/useToast'; +import { useOrderTimeoutPolling } from '@/shared/composables/useOrderTimeoutPolling'; import type { OrderDetailData, OrderItemSnapshot } from '../types'; const RECENT_ORDER_WINDOW_MS = 10 * 60 * 1000; // 10 分钟 @@ -173,6 +175,20 @@ function onCancelBuyAgain() { pendingBuyItem.value = null; } +const { start: startTimeoutPolling } = useOrderTimeoutPolling( + orderingApi.getOrderDetail, + () => { + // 订单离开 PENDING_PAYMENT(被超时取消或已支付),刷新详情展示最终状态。 + if (order.value) void loadOrder(order.value.orderId); + }, +); + +function handleExpired() { + if (order.value) { + startTimeoutPolling(order.value.orderId); + } +} + function goPay() { router.push(`/orders/${order.value!.orderId}/payment`); } @@ -397,6 +413,13 @@ async function handleRefund() { + +
diff --git a/frontend/src/modules/payment/pages/PaymentPage.spec.ts b/frontend/src/modules/payment/pages/PaymentPage.spec.ts index 5612ff4..452e3cc 100644 --- a/frontend/src/modules/payment/pages/PaymentPage.spec.ts +++ b/frontend/src/modules/payment/pages/PaymentPage.spec.ts @@ -12,12 +12,17 @@ const pageMocks = vi.hoisted(() => ({ confirm: vi.fn(), clearCurrentPayment: vi.fn(), }, + getOrderDetail: vi.fn(), })); vi.mock('../stores/paymentStore', () => ({ usePaymentStore: () => pageMocks.store, })); +vi.mock('@/modules/ordering/api', () => ({ + getOrderDetail: pageMocks.getOrderDetail, +})); + function payment(orderId: string): PaymentSummary { return { paymentId: `payment-${orderId}`, @@ -45,6 +50,9 @@ describe('PaymentPage', () => { vi.clearAllMocks(); pageMocks.store.error = ''; pageMocks.store.create.mockImplementation(async (orderId: string) => payment(orderId)); + pageMocks.getOrderDetail.mockImplementation(async (orderId: string) => ({ + data: { orderId, status: 'PENDING_PAYMENT', placedAt: '2026-08-04T08:00:00Z' }, + })); }); it('reloads payment data when the route reuses the page for another order', async () => { diff --git a/frontend/src/modules/payment/pages/PaymentPage.vue b/frontend/src/modules/payment/pages/PaymentPage.vue index 36dc96c..1d16956 100644 --- a/frontend/src/modules/payment/pages/PaymentPage.vue +++ b/frontend/src/modules/payment/pages/PaymentPage.vue @@ -7,6 +7,9 @@ import { ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { usePaymentStore } from '../stores/paymentStore'; +import OrderPaymentCountdown from '@/shared/components/OrderPaymentCountdown.vue'; +import { useOrderTimeoutPolling } from '@/shared/composables/useOrderTimeoutPolling'; +import * as orderingApi from '../../ordering/api'; import { PaymentStatusLabel } from '../types'; import type { PaymentSummary } from '../types'; @@ -15,6 +18,7 @@ const router = useRouter(); const store = usePaymentStore(); const activeOrderId = ref(''); +const orderPlacedAt = ref(''); const payment = ref(null); const loading = ref(true); const error = ref(''); @@ -44,7 +48,19 @@ watch( } activeOrderId.value = orderId; + orderPlacedAt.value = ''; loading.value = true; + + // 倒计时起点须用订单下单时间(后端超时从 PlacedAt 起算 30 分钟), + // 而 payment.createdAt 是进入本页才创建的支付记录时间,二者不一致。 + try { + const { data: order } = await orderingApi.getOrderDetail(orderId); + if (generation !== pageGeneration) return; + orderPlacedAt.value = order.placedAt; + } catch { + orderPlacedAt.value = ''; + } + const result = await store.create(orderId); if (generation !== pageGeneration) return; @@ -55,6 +71,22 @@ watch( { immediate: true }, ); +const { start: startTimeoutPolling } = useOrderTimeoutPolling( + orderingApi.getOrderDetail, + (status) => { + if (status === 'CANCELLED') { + alert('订单已超时取消'); + router.replace('/orders'); + } + }, +); + +function handleExpired() { + if (activeOrderId.value) { + startTimeoutPolling(activeOrderId.value); + } +} + async function handleConfirm() { if (!payment.value) return; const generation = pageGeneration; @@ -83,6 +115,13 @@ async function handleConfirm() {

核对订单与金额后完成模拟支付。

+ +
正在创建安全支付记录...

{{ error }}

diff --git a/frontend/src/shared/components/OrderPaymentCountdown.vue b/frontend/src/shared/components/OrderPaymentCountdown.vue new file mode 100644 index 0000000..bf56d77 --- /dev/null +++ b/frontend/src/shared/components/OrderPaymentCountdown.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/shared/composables/useOrderTimeoutPolling.spec.ts b/frontend/src/shared/composables/useOrderTimeoutPolling.spec.ts new file mode 100644 index 0000000..af58a07 --- /dev/null +++ b/frontend/src/shared/composables/useOrderTimeoutPolling.spec.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useOrderTimeoutPolling } from './useOrderTimeoutPolling'; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useOrderTimeoutPolling', () => { + it('start 后立即查询一次,订单离开 PENDING_PAYMENT 时回调最终状态', async () => { + const fetchStatus = vi.fn().mockResolvedValue({ data: { status: 'CANCELLED' } }); + const onSettled = vi.fn(); + const { start } = useOrderTimeoutPolling(fetchStatus, onSettled); + + start('101'); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(1); + expect(fetchStatus).toHaveBeenCalledWith('101'); + expect(onSettled).toHaveBeenCalledTimes(1); + expect(onSettled).toHaveBeenCalledWith('CANCELLED'); + }); + + it('订单持续 PENDING_PAYMENT 时按间隔轮询,达到 maxAttempts 后停止', async () => { + const fetchStatus = vi + .fn() + .mockResolvedValue({ data: { status: 'PENDING_PAYMENT' } }); + const onSettled = vi.fn(); + const { start } = useOrderTimeoutPolling(fetchStatus, onSettled, { + intervalMs: 5000, + maxAttempts: 3, + }); + + start('101'); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(3); + expect(onSettled).not.toHaveBeenCalled(); + }); + + it('stop 后不再轮询', async () => { + const fetchStatus = vi + .fn() + .mockResolvedValue({ data: { status: 'PENDING_PAYMENT' } }); + const onSettled = vi.fn(); + const { start, stop } = useOrderTimeoutPolling(fetchStatus, onSettled); + + start('101'); + await vi.advanceTimersByTimeAsync(0); + stop(); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(1); + expect(onSettled).not.toHaveBeenCalled(); + }); + + it('单次查询失败不中断轮询,下一轮继续', async () => { + const fetchStatus = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValue({ data: { status: 'CANCELLED' } }); + const onSettled = vi.fn(); + const { start } = useOrderTimeoutPolling(fetchStatus, onSettled, { + intervalMs: 5000, + }); + + start('101'); + await vi.runAllTimersAsync(); + + expect(fetchStatus).toHaveBeenCalledTimes(2); + expect(onSettled).toHaveBeenCalledWith('CANCELLED'); + }); +}); diff --git a/frontend/src/shared/composables/useOrderTimeoutPolling.ts b/frontend/src/shared/composables/useOrderTimeoutPolling.ts new file mode 100644 index 0000000..a57abfa --- /dev/null +++ b/frontend/src/shared/composables/useOrderTimeoutPolling.ts @@ -0,0 +1,81 @@ +/** + * 订单超时轮询:倒计时归零后周期查询订单状态,直到其离开 PENDING_PAYMENT。 + * + * 存在的理由:后端 OrderTimeoutWorker 每 60 秒才扫描一次,前端倒计时归零只代表 + * "本地时间到了",订单可能还要再等最多 60 秒才真正被取消为 CANCELLED。若只在 + * 归零瞬间查一次,很可能仍读到 PENDING_PAYMENT 而漏掉最终结果。本模块在归零后 + * 按固定间隔轮询,直到订单进入终态(CANCELLED / PAID)再通过 onSettled 回调。 + * + * 与 UI 倒计时组件解耦:倒计时组件只负责"本地剩余时间",不关心订单、不发请求; + * 页面把"归零后确认真实状态"交给本模块。 + */ +import { getCurrentInstance, onUnmounted } from 'vue'; + +export interface OrderTimeoutPollingOptions { + /** 轮询间隔(毫秒)。后端扫描间隔 60 秒,5 秒足够及时感知,又不过度请求。 */ + intervalMs?: number; + /** 最大轮询次数,避免订单长期悬而未决时无限轮询。默认 24 次(约 2 分钟)。 */ + maxAttempts?: number; +} + +/** 订单状态查询函数,形如 orderingApi.getOrderDetail 的返回结构。 */ +export type OrderStatusFetcher = ( + orderId: string, +) => Promise<{ data: { status: string } }>; + +export function useOrderTimeoutPolling( + fetchStatus: OrderStatusFetcher, + onSettled: (status: string) => void, + options?: OrderTimeoutPollingOptions, +) { + const intervalMs = options?.intervalMs ?? 5000; + const maxAttempts = options?.maxAttempts ?? 24; + + let timer: ReturnType | null = null; + let attempts = 0; + let inFlight = false; + + function stop() { + if (timer !== null) { + clearInterval(timer); + timer = null; + } + attempts = 0; + inFlight = false; + } + + async function poll(orderId: string) { + if (inFlight) return; + inFlight = true; + attempts += 1; + try { + const { data } = await fetchStatus(orderId); + if (data.status !== 'PENDING_PAYMENT') { + stop(); + onSettled(data.status); + return; + } + } catch { + // 单次查询失败静默,等下一轮重试;不因瞬时网络抖动中断轮询。 + } finally { + inFlight = false; + } + if (attempts >= maxAttempts) stop(); + } + + function start(orderId: string) { + stop(); + if (!orderId) return; + // 先立即查一次,避免归零后还要等一个完整间隔才首次确认状态。 + void poll(orderId); + timer = setInterval(() => void poll(orderId), intervalMs); + } + + // 有组件实例时自动清理;测试/非组件上下文(getCurrentInstance 为空)跳过, + // 避免 onUnmounted 在无实例环境下告警。 + if (getCurrentInstance()) { + onUnmounted(stop); + } + + return { start, stop }; +} -- Gitee