Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,6 @@ plan.md
### Docker 运行时数据与敏感配置 ###
.env
.env.properties
tmp_*secrets*.txt
config/rootCA.pem
config/nginx/certs/
2 changes: 2 additions & 0 deletions frontend/apps/site/app/pages/blog/[id].vue
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,12 @@ useSeoMeta({
<USeparator class="mb-8" />

<!-- 文章内容 -->
<!-- eslint-disable vue/no-v-html -->
<div
class="prose prose-neutral dark:prose-invert max-w-none"
v-html="sanitizedContent"
/>
<!-- eslint-enable vue/no-v-html -->

<USeparator class="my-10" />

Expand Down
45 changes: 44 additions & 1 deletion frontend/layers/base/composables/useSanitizedHtml.ts
Original file line number Diff line number Diff line change
@@ -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 清洗,保留文章渲染所需的安全标签和属性。
*
Expand All @@ -20,7 +64,6 @@ export function useSanitizedHtml(html: MaybeRef<string>): ComputedRef<string> |
// 禁止表单和脚本
FORBID_TAGS: ['form', 'input', 'textarea', 'select', 'button', 'style'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
// iframe 仅允许白名单域
ALLOW_UNKNOWN_PROTOCOLS: false,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,7 +42,7 @@ public GlobalResult<List<Product>> listProducts(
@GetMapping("/{id}")
@ApiLog("查询产品详情")
@Operation(summary = "获取上架产品详情")
public GlobalResult<Product> getProductDetail(
public GlobalResult<ProductDetailVO> getProductDetail(
@Parameter(description = "产品ID") @PathVariable("id") Long id) {
return GlobalResult.success(productCatalogApiFacade.getProductDetail(id));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> galleryImages;
private String productType;
private Long categoryId;
private String[] tags;
private Map<String, Object> features;
private Map<String, Object> 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<SkuTargetVO> skuTargets;
}
Comment on lines +15 to +44

Copilot AI May 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProductDetailVO 继承了 Product 实体类,会把 createdBydelFlag 等领域/持久化字段一并暴露到公开接口返回值中,这与 PR 描述里“隐藏内部元数据、删除标记等字段”的目标相冲突,也容易造成不必要的数据泄露。建议改为独立的 DTO(只声明需要对外暴露的字段),或至少通过 @JsonIgnoreProperties/@JsonIgnore 明确屏蔽这些内部字段,而不是继承实体。

Copilot uses AI. Check for mistakes.

@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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,7 +10,7 @@ public interface ProductCatalogApiFacade {

List<Product> listProducts(String productType);

Product getProductDetail(Long id);
ProductDetailVO getProductDetail(Long id);

Map<String, String> listProductTypes();
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,8 +43,39 @@ public List<Product> 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<ProductSku> skuList = productSkuQueryService.findByProductId(id);
if (skuList.isEmpty()) {
detailVO.setSkuTargets(Collections.emptyList());
return detailVO;
}

List<Long> 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProductSkuTarget> {

List<ProductSkuTarget> findByProductSkuId(Long productSkuId);

List<ProductSkuTarget> findByProductSkuIds(Collection<Long> productSkuIds);
}
Original file line number Diff line number Diff line change
@@ -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<ProductSkuTargetMapper, ProductSkuTarget>
implements ProductSkuTargetService {

@Override
public List<ProductSkuTarget> 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<ProductSkuTarget> findByProductSkuIds(Collection<Long> 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())
);
}
}
Original file line number Diff line number Diff line change
@@ -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<ProductSkuTarget> findByProductSkuId(Long productSkuId);

List<ProductSkuTarget> findByProductSkuIds(Collection<Long> productSkuIds);

ProductSkuTarget getById(Long id);
}
Original file line number Diff line number Diff line change
@@ -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<ProductSkuTarget> findByProductSkuId(Long productSkuId) {
return productSkuTargetService.findByProductSkuId(productSkuId);
}

@Override
public List<ProductSkuTarget> findByProductSkuIds(Collection<Long> productSkuIds) {
return productSkuTargetService.findByProductSkuIds(productSkuIds);
}

@Override
public ProductSkuTarget getById(Long id) {
return productSkuTargetService.getById(id);
}
}
Loading