diff --git a/.gitignore b/.gitignore
index 49b90392..0e537acc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,5 +47,6 @@ plan.md
### Docker 运行时数据与敏感配置 ###
.env
.env.properties
+tmp_*secrets*.txt
config/rootCA.pem
config/nginx/certs/
diff --git a/frontend/apps/site/app/pages/blog/[id].vue b/frontend/apps/site/app/pages/blog/[id].vue
index f4d5b7c4..3807e3c3 100644
--- a/frontend/apps/site/app/pages/blog/[id].vue
+++ b/frontend/apps/site/app/pages/blog/[id].vue
@@ -83,10 +83,12 @@ useSeoMeta({
+
+
diff --git a/frontend/layers/base/composables/useSanitizedHtml.ts b/frontend/layers/base/composables/useSanitizedHtml.ts
index 6f6694ba..b82d286f 100644
--- a/frontend/layers/base/composables/useSanitizedHtml.ts
+++ b/frontend/layers/base/composables/useSanitizedHtml.ts
@@ -1,5 +1,49 @@
import DOMPurify from 'isomorphic-dompurify'
+type SanitizeElementHookData = {
+ tagName: string
+}
+
+type DOMPurifyWithHooks = typeof DOMPurify & {
+ addHook: (
+ entryPoint: 'uponSanitizeElement',
+ hook: (node: Node, data: SanitizeElementHookData) => void
+ ) => void
+}
+
+const ALLOWED_IFRAME_HOSTS = new Set([
+ 'player.bilibili.com',
+ 'www.youtube.com',
+ 'www.youtube-nocookie.com',
+ 'player.vimeo.com',
+ 'open.spotify.com',
+ 'music.163.com'
+])
+
+function isAllowedIframeSrc(src: string | null): boolean {
+ if (!src) {
+ return false
+ }
+
+ try {
+ const url = new URL(src)
+ return url.protocol === 'https:' && ALLOWED_IFRAME_HOSTS.has(url.hostname)
+ } catch {
+ return false
+ }
+}
+
+(DOMPurify as DOMPurifyWithHooks).addHook('uponSanitizeElement', (node, data) => {
+ if (data.tagName !== 'iframe') {
+ return
+ }
+
+ const element = node as Element
+ if (!isAllowedIframeSrc(element.getAttribute('src'))) {
+ element.remove()
+ }
+})
+
/**
* 对 HTML 字符串进行 XSS 清洗,保留文章渲染所需的安全标签和属性。
*
@@ -20,7 +64,6 @@ export function useSanitizedHtml(html: MaybeRef): ComputedRef |
// 禁止表单和脚本
FORBID_TAGS: ['form', 'input', 'textarea', 'select', 'button', 'style'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
- // iframe 仅允许白名单域
ALLOW_UNKNOWN_PROTOCOLS: false,
})
diff --git a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/controller/ProductApiController.java b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/controller/ProductApiController.java
index ba685473..5f301d63 100644
--- a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/controller/ProductApiController.java
+++ b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/controller/ProductApiController.java
@@ -2,6 +2,7 @@
import com.rymcu.mortise.core.result.GlobalResult;
import com.rymcu.mortise.log.annotation.ApiLog;
+import com.rymcu.mortise.product.api.dto.ApiProductModels.ProductDetailVO;
import com.rymcu.mortise.product.api.facade.ProductCatalogApiFacade;
import com.rymcu.mortise.product.entity.Product;
import com.rymcu.mortise.web.annotation.ApiController;
@@ -41,7 +42,7 @@ public GlobalResult> listProducts(
@GetMapping("/{id}")
@ApiLog("查询产品详情")
@Operation(summary = "获取上架产品详情")
- public GlobalResult getProductDetail(
+ public GlobalResult getProductDetail(
@Parameter(description = "产品ID") @PathVariable("id") Long id) {
return GlobalResult.success(productCatalogApiFacade.getProductDetail(id));
}
diff --git a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/dto/ApiProductModels.java b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/dto/ApiProductModels.java
new file mode 100644
index 00000000..dae98c80
--- /dev/null
+++ b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/dto/ApiProductModels.java
@@ -0,0 +1,57 @@
+package com.rymcu.mortise.product.api.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+public final class ApiProductModels {
+
+ private ApiProductModels() {
+ }
+
+ @Data
+ public static class ProductDetailVO {
+
+ private Long id;
+ private String productCode;
+ private String title;
+ private String subtitle;
+ private String description;
+ private String shortDescription;
+ private String coverImageUrl;
+ private List galleryImages;
+ private String productType;
+ private Long categoryId;
+ private String[] tags;
+ private Map features;
+ private Map specifications;
+ private String seoTitle;
+ private String seoDescription;
+ private String seoKeywords;
+ private Integer status;
+ private Boolean isFeatured;
+ private Integer sortNo;
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime createdTime;
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime updatedTime;
+ @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+ private LocalDateTime publishedTime;
+ private List skuTargets;
+ }
+
+ @Data
+ public static class SkuTargetVO {
+
+ private Long id;
+ private Long productSkuId;
+ private String targetType;
+ private Long targetId;
+ private Integer quantity;
+ private Integer validityDays;
+ private String accessLevel;
+ }
+}
diff --git a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/ProductCatalogApiFacade.java b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/ProductCatalogApiFacade.java
index 407d89a8..b3158d2a 100644
--- a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/ProductCatalogApiFacade.java
+++ b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/ProductCatalogApiFacade.java
@@ -1,5 +1,6 @@
package com.rymcu.mortise.product.api.facade;
+import com.rymcu.mortise.product.api.dto.ApiProductModels.ProductDetailVO;
import com.rymcu.mortise.product.entity.Product;
import java.util.List;
@@ -9,7 +10,7 @@ public interface ProductCatalogApiFacade {
List listProducts(String productType);
- Product getProductDetail(Long id);
+ ProductDetailVO getProductDetail(Long id);
Map listProductTypes();
}
diff --git a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java
index e12360ba..a9644d9a 100644
--- a/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java
+++ b/mortise-product/mortise-product-api/src/main/java/com/rymcu/mortise/product/api/facade/impl/ProductCatalogApiFacadeImpl.java
@@ -1,20 +1,40 @@
package com.rymcu.mortise.product.api.facade.impl;
+import com.rymcu.mortise.common.enumerate.Status;
+import com.rymcu.mortise.product.api.dto.ApiProductModels.ProductDetailVO;
+import com.rymcu.mortise.product.api.dto.ApiProductModels.SkuTargetVO;
import com.rymcu.mortise.product.api.facade.ProductCatalogApiFacade;
import com.rymcu.mortise.product.entity.Product;
+import com.rymcu.mortise.product.entity.ProductSku;
import com.rymcu.mortise.product.service.query.ProductQueryService;
+import com.rymcu.mortise.product.service.query.ProductSkuQueryService;
+import com.rymcu.mortise.product.service.query.ProductSkuTargetQueryService;
+import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.stream.Collectors;
@Component
public class ProductCatalogApiFacadeImpl implements ProductCatalogApiFacade {
+ private static final String ACTIVE_SKU_STATUS = "active";
+ private static final Integer ENABLED_TARGET_STATUS = Status.ENABLED.getCode();
+
private final ProductQueryService productQueryService;
+ private final ProductSkuQueryService productSkuQueryService;
+ private final ProductSkuTargetQueryService productSkuTargetQueryService;
- public ProductCatalogApiFacadeImpl(ProductQueryService productQueryService) {
+ public ProductCatalogApiFacadeImpl(
+ ProductQueryService productQueryService,
+ ProductSkuQueryService productSkuQueryService,
+ ProductSkuTargetQueryService productSkuTargetQueryService
+ ) {
this.productQueryService = productQueryService;
+ this.productSkuQueryService = productSkuQueryService;
+ this.productSkuTargetQueryService = productSkuTargetQueryService;
}
@Override
@@ -23,8 +43,39 @@ public List listProducts(String productType) {
}
@Override
- public Product getProductDetail(Long id) {
- return productQueryService.findPublishedById(id);
+ public ProductDetailVO getProductDetail(Long id) {
+ Product product = productQueryService.findPublishedById(id);
+ if (product == null) {
+ return null;
+ }
+
+ ProductDetailVO detailVO = new ProductDetailVO();
+ BeanUtils.copyProperties(product, detailVO);
+
+ List skuList = productSkuQueryService.findByProductId(id);
+ if (skuList.isEmpty()) {
+ detailVO.setSkuTargets(Collections.emptyList());
+ return detailVO;
+ }
+
+ List skuIds = skuList.stream()
+ .filter(sku -> ACTIVE_SKU_STATUS.equals(sku.getStatus()))
+ .map(ProductSku::getId)
+ .toList();
+ if (skuIds.isEmpty()) {
+ detailVO.setSkuTargets(Collections.emptyList());
+ return detailVO;
+ }
+
+ detailVO.setSkuTargets(productSkuTargetQueryService.findByProductSkuIds(skuIds).stream()
+ .filter(target -> ENABLED_TARGET_STATUS.equals(target.getStatus()))
+ .map(target -> {
+ SkuTargetVO targetVO = new SkuTargetVO();
+ BeanUtils.copyProperties(target, targetVO);
+ return targetVO;
+ })
+ .collect(Collectors.toList()));
+ return detailVO;
}
@Override
diff --git a/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/ProductSkuTargetService.java b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/ProductSkuTargetService.java
new file mode 100644
index 00000000..76466c8b
--- /dev/null
+++ b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/ProductSkuTargetService.java
@@ -0,0 +1,19 @@
+package com.rymcu.mortise.product.service;
+
+import com.mybatisflex.core.service.IService;
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * 产品 SKU 目标映射服务
+ *
+ * @author ronger
+ */
+public interface ProductSkuTargetService extends IService {
+
+ List findByProductSkuId(Long productSkuId);
+
+ List findByProductSkuIds(Collection productSkuIds);
+}
diff --git a/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/impl/ProductSkuTargetServiceImpl.java b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/impl/ProductSkuTargetServiceImpl.java
new file mode 100644
index 00000000..5fdb13ef
--- /dev/null
+++ b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/impl/ProductSkuTargetServiceImpl.java
@@ -0,0 +1,39 @@
+package com.rymcu.mortise.product.service.impl;
+
+import com.mybatisflex.core.query.QueryWrapper;
+import com.mybatisflex.spring.service.impl.ServiceImpl;
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+import com.rymcu.mortise.product.mapper.ProductSkuTargetMapper;
+import com.rymcu.mortise.product.service.ProductSkuTargetService;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import java.util.List;
+
+import static com.rymcu.mortise.product.entity.table.ProductSkuTargetTableDef.PRODUCT_SKU_TARGET;
+
+@Service
+public class ProductSkuTargetServiceImpl extends ServiceImpl
+ implements ProductSkuTargetService {
+
+ @Override
+ public List findByProductSkuId(Long productSkuId) {
+ return mapper.selectListByQuery(
+ QueryWrapper.create()
+ .where(PRODUCT_SKU_TARGET.PRODUCT_SKU_ID.eq(productSkuId))
+ .orderBy(PRODUCT_SKU_TARGET.CREATED_TIME.asc())
+ );
+ }
+
+ @Override
+ public List findByProductSkuIds(Collection productSkuIds) {
+ if (productSkuIds == null || productSkuIds.isEmpty()) {
+ return List.of();
+ }
+ return mapper.selectListByQuery(
+ QueryWrapper.create()
+ .where(PRODUCT_SKU_TARGET.PRODUCT_SKU_ID.in(productSkuIds))
+ .orderBy(PRODUCT_SKU_TARGET.PRODUCT_SKU_ID.asc(), PRODUCT_SKU_TARGET.CREATED_TIME.asc())
+ );
+ }
+}
diff --git a/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/query/ProductSkuTargetQueryService.java b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/query/ProductSkuTargetQueryService.java
new file mode 100644
index 00000000..467c4e3f
--- /dev/null
+++ b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/query/ProductSkuTargetQueryService.java
@@ -0,0 +1,15 @@
+package com.rymcu.mortise.product.service.query;
+
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+
+import java.util.Collection;
+import java.util.List;
+
+public interface ProductSkuTargetQueryService {
+
+ List findByProductSkuId(Long productSkuId);
+
+ List findByProductSkuIds(Collection productSkuIds);
+
+ ProductSkuTarget getById(Long id);
+}
diff --git a/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/query/impl/ProductSkuTargetQueryServiceImpl.java b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/query/impl/ProductSkuTargetQueryServiceImpl.java
new file mode 100644
index 00000000..5a69d047
--- /dev/null
+++ b/mortise-product/mortise-product-application/src/main/java/com/rymcu/mortise/product/service/query/impl/ProductSkuTargetQueryServiceImpl.java
@@ -0,0 +1,34 @@
+package com.rymcu.mortise.product.service.query.impl;
+
+import com.rymcu.mortise.product.entity.ProductSkuTarget;
+import com.rymcu.mortise.product.service.ProductSkuTargetService;
+import com.rymcu.mortise.product.service.query.ProductSkuTargetQueryService;
+import org.springframework.stereotype.Service;
+
+import java.util.Collection;
+import java.util.List;
+
+@Service
+public class ProductSkuTargetQueryServiceImpl implements ProductSkuTargetQueryService {
+
+ private final ProductSkuTargetService productSkuTargetService;
+
+ public ProductSkuTargetQueryServiceImpl(ProductSkuTargetService productSkuTargetService) {
+ this.productSkuTargetService = productSkuTargetService;
+ }
+
+ @Override
+ public List findByProductSkuId(Long productSkuId) {
+ return productSkuTargetService.findByProductSkuId(productSkuId);
+ }
+
+ @Override
+ public List findByProductSkuIds(Collection productSkuIds) {
+ return productSkuTargetService.findByProductSkuIds(productSkuIds);
+ }
+
+ @Override
+ public ProductSkuTarget getById(Long id) {
+ return productSkuTargetService.getById(id);
+ }
+}
diff --git a/mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMessageController.java b/mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMessageController.java
index c30ec330..231a55c9 100644
--- a/mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMessageController.java
+++ b/mortise-wechat/src/main/java/com/rymcu/mortise/wechat/controller/WeChatMessageController.java
@@ -1,7 +1,7 @@
package com.rymcu.mortise.wechat.controller;
-import com.rymcu.mortise.web.annotation.ApiController;
import com.rymcu.mortise.core.result.GlobalResult;
+import com.rymcu.mortise.web.annotation.AdminController;
import com.rymcu.mortise.wechat.entity.TemplateMessage;
import com.rymcu.mortise.wechat.facade.WeChatMessageFacade;
import com.rymcu.mortise.wechat.facade.WeChatMessageFacade.NewsMessageRequest;
@@ -10,6 +10,7 @@
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
+import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@@ -20,7 +21,7 @@
* @author ronger
* @since 1.0.0
*/
-@ApiController
+@AdminController
@RequestMapping("/wechat/messages")
@RequiredArgsConstructor
@ConditionalOnBean(WeChatMessageFacade.class)
@@ -38,6 +39,7 @@ public class WeChatMessageController {
*/
@Operation(summary = "发送模板消息", description = "发送微信模板消息给指定用户")
@PostMapping("/template")
+ @PreAuthorize("hasAuthority('wechat:message:send')")
public GlobalResult