支付与对账 / 搜索与推荐 / 评价系统
1. 支付与对账
1.1 支付渠道统一抽象
多支付渠道接入时,通过统一的 PaymentChannel 接口抽象各渠道差异,支持策略模式动态切换。
PaymentChannel 接口定义
java
public interface PaymentChannel {
/**
* 发起支付请求
*/
PaymentResponse pay(PaymentRequest request);
/**
* 处理支付回调
*/
CallbackResult handleCallback(CallbackRequest request);
/**
* 查询订单状态
*/
OrderStatus queryOrder(String orderNo);
/**
* 发起退款
*/
RefundResponse refund(RefundRequest request);
/**
* 查询退款状态
*/
RefundStatus queryRefund(String refundNo);
/**
* 下载对账单
*/
List<BillRecord> downloadBill(String billDate);
/**
* 获取渠道标识
*/
String getChannel();
}各渠道实现
微信支付 (WechatPay)
java
@Component
public class WechatPay implements PaymentChannel {
@Override
public PaymentResponse pay(PaymentRequest request) {
// 构建微信 JSAPI/Native/App 统一下单请求
// 调用微信支付 SDK,获取 prepay_id
// 返回支付凭证给前端调起支付
return PaymentResponse.success(prepayId);
}
@Override
public CallbackResult handleCallback(CallbackRequest request) {
// 验证签名(微信使用 HMAC-SHA256 或 MD5)
// 解析回调报文,获取订单号、交易号、实付金额
// 幂等处理:检查本地订单状态,避免重复更新
return CallbackResult.success(transId, orderNo, amount);
}
@Override
public List<BillRecord> downloadBill(String billDate) {
// 调用微信账单下载 API(https://api.mch.weixin.qq.com/v3/bill/tradebill)
// 解析 CSV/JSON 格式账单文件
// 返回标准化 BillRecord 列表
return wechatBillParser.parse(rawBillData);
}
}支付宝 (Alipay)
java
@Component
public class Alipay implements PaymentChannel {
@Override
public PaymentResponse pay(PaymentRequest request) {
// 构建支付宝支付请求(alipay.trade.precreate / alipay.trade.create)
// 使用 AlipayClient 执行请求
// 返回 trade_no 和付款链接/二维码
return PaymentResponse.success(qrCodeUrl);
}
@Override
public CallbackResult handleCallback(CallbackRequest request) {
// 使用支付宝公钥验签
// 验证通知类型、订单状态(TRADE_SUCCESS)
// 幂等处理
return CallbackResult.success(tradeNo, outTradeNo, totalAmount);
}
@Override
public List<BillRecord> downloadBill(String billDate) {
// 调用支付宝对账单下载接口(alipay.data.dataservice.bill.downloadurl.query)
// 获取下载链接后拉取 CSV 文件
return alipayBillParser.parse(csvContent);
}
}银联支付 (UnionPay)
java
@Component
public class UnionPay implements PaymentChannel {
@Override
public PaymentResponse pay(PaymentRequest request) {
// 银联消费类交易,组装 REST 请求报文
// 使用银联证书签名
// 返回 HTML 表单或二维码
return PaymentResponse.success(formHtml);
}
@Override
public CallbackResult handleCallback(CallbackRequest request) {
// 银联证书验签
// 验证响应码(00 表示成功)
return CallbackResult.success(queryId, orderId, settleAmt);
}
@Override
public List<BillRecord> downloadBill(String billDate) {
// 银联文件传输接口获取对账文件
// 解析 9001 格式或 CSV 格式账单
return unionPayBillParser.parse(fileContent);
}
}PaymentChannelFactory
java
@Component
public class PaymentChannelFactory {
@Autowired
private Map<String, PaymentChannel> channelMap;
public PaymentChannel getChannel(String channel) {
PaymentChannel pc = channelMap.get(channel);
if (pc == null) {
throw new IllegalArgumentException("Unsupported channel: " + channel);
}
return pc;
}
}1.2 对账流程设计
对账核心目标是确保平台交易记录与支付渠道账单记录逐笔一致,发现差异及时处理。
对账整体流程
平台交易订单库 ──┐
├──> 对账引擎 ──> 差异记录 ──> 差错处理池
支付渠道账单 ──┘对账引擎核心逻辑
java
@Component
public class ReconciliationEngine {
/**
* 执行单日对账
*/
public ReconciliationResult reconcile(String billDate) {
// 1. 获取平台交易记录(已支付成功订单)
List<PlatformOrder> platformOrders = orderService.queryPaidOrders(billDate);
// 2. 获取各渠道对账单
List<BillRecord> channelBills = new ArrayList<>();
for (PaymentChannel channel : channelFactory.getAllChannels()) {
channelBills.addAll(channel.downloadBill(billDate));
}
// 3. 构建 Map 加速匹配
Map<String, PlatformOrder> platformMap = platformOrders.stream()
.collect(Collectors.toMap(PlatformOrder::getOrderNo, Function.identity()));
Map<String, BillRecord> channelMap = channelBills.stream()
.collect(Collectors.toMap(BillRecord::getOrderNo, Function.identity()));
// 4. 逐笔核对
List<ReconciliationDiff> diffs = new ArrayList<>();
// 4a. 平台有、渠道无(平台长款 / 渠道短款)
for (PlatformOrder order : platformOrders) {
if (!channelMap.containsKey(order.getOrderNo())) {
diffs.add(new ReconciliationDiff(order.getOrderNo(),
DiffType.PLATFORM_LONG, order.getAmount(), null));
}
}
// 4b. 渠道有、平台无(平台短款 / 渠道长款)
for (BillRecord bill : channelBills) {
if (!platformMap.containsKey(bill.getOrderNo())) {
diffs.add(new ReconciliationDiff(bill.getOrderNo(),
DiffType.CHANNEL_LONG, null, bill.getAmount()));
}
}
// 4c. 双方都有但金额不一致
for (PlatformOrder order : platformOrders) {
BillRecord bill = channelMap.get(order.getOrderNo());
if (bill != null && !order.getAmount().equals(bill.getAmount())) {
diffs.add(new ReconciliationDiff(order.getOrderNo(),
DiffType.AMOUNT_MISMATCH, order.getAmount(), bill.getAmount()));
}
}
return new ReconciliationResult(billDate, diffs);
}
}对账结果模型
java
public class ReconciliationDiff {
private String orderNo; // 订单号
private DiffType diffType; // 差异类型
private BigDecimal platformAmount; // 平台金额
private BigDecimal channelAmount; // 渠道金额
private String channel; // 支付渠道
private String status; // PENDING / PROCESSED / CLOSED
private LocalDateTime createTime;
private LocalDateTime processTime;
private String processRemark; // 处理备注
}
public enum DiffType {
PLATFORM_LONG, // 平台长款(平台有记录,渠道无)
CHANNEL_LONG, // 渠道长款(渠道有记录,平台无)
AMOUNT_MISMATCH, // 金额不一致
}1.3 对账差异处理
| 差异类型 | 定义 | 处理策略 |
|---|---|---|
| 长款(平台长款) | 平台有支付记录,渠道账单中没有 | 检查是否渠道延迟;等待次日对账;如持续无记录则触发人工核查 |
| 短款(渠道长款) | 渠道账单中有记录,平台没有 | 优先检查是否平台漏单或支付回调丢失;调用渠道查询接口确认状态;补单处理 |
| 金额不一致 | 平台记录金额与渠道账单金额不同 | 保留双方记录,标记为差错单,人工介入核实 |
| 无单 | 渠道账单中存在不认识的订单号 | 可能为其他系统订单或测试单,标记观察 |
差异自动处理策略
java
@Component
public class DiffAutoProcessor {
@Autowired
private ReconciliationEngine reconciliationEngine;
public void processDiff(ReconciliationDiff diff) {
switch (diff.getDiffType()) {
case PLATFORM_LONG:
// 当日长款:等待次日对账兜底
// 跨日长款:触发人工排查
if (Duration.between(diff.getCreateTime(), LocalDateTime.now()).toDays() > 1) {
diff.setStatus("PENDING_MANUAL");
}
break;
case CHANNEL_LONG:
// 主动调用支付渠道查询接口确认状态
PaymentChannel channel = channelFactory.getChannel(diff.getChannel());
OrderStatus status = channel.queryOrder(diff.getOrderNo());
if (status.isSuccess()) {
// 缺单:执行补单操作
orderService.supplementOrder(diff.getOrderNo(), status);
diff.setStatus("PROCESSED");
diff.setProcessRemark("渠道补单完成");
} else {
diff.setStatus("PENDING_MANUAL");
}
break;
case AMOUNT_MISMATCH:
// 金额差异直接进入人工处理
diff.setStatus("PENDING_MANUAL");
break;
}
}
}1.4 每日对账定时任务设计
使用 XXL-Job 或 Quartz 调度每日对账任务。建议对账时间为凌晨 02:00(渠道账单通常在次日凌晨生成完毕)。
定时任务配置
java
@Component
public class ReconciliationScheduler {
@Autowired
private ReconciliationEngine reconciliationEngine;
@Autowired
private DiffAutoProcessor diffAutoProcessor;
@Autowired
private ErrorPoolService errorPoolService;
/**
* 每日对账主任务,由调度中心触发
* cron: 0 0 2 * * ? (每天凌晨 2 点执行)
*/
@XxlJob("dailyReconciliation")
public ReturnT<String> dailyReconciliation(String param) {
String billDate = LocalDate.now().minusDays(1).format(DateTimeFormatter.ofPattern("yyyyMMdd"));
log.info("开始对账,日期: {}", billDate);
// Step 1: 执行对账
ReconciliationResult result = reconciliationEngine.reconcile(billDate);
log.info("对账完成,差异数: {}", result.getDiffs().size());
// Step 2: 自动处理可修复差异
for (ReconciliationDiff diff : result.getDiffs()) {
diffAutoProcessor.processDiff(diff);
}
// Step 3: 将未处理的差异写入差错处理池
for (ReconciliationDiff diff : result.getDiffs()) {
if ("PENDING_MANUAL".equals(diff.getStatus())) {
errorPoolService.addErrorRecord(diff);
}
}
// Step 4: 发送对账报告通知
notificationService.sendReconciliationReport(result);
return ReturnT.SUCCESS;
}
}对账任务执行流程
02:00 ──> 触发对账任务
├── 下载各渠道账单(重试 3 次,间隔 5 分钟)
├── 执行逐笔核对
├── 自动处理可修复差异
├── 未处理差异进入差错池
└── 发送对账报告(企业微信 / 钉钉 / 邮件)1.5 差错处理池与人工介入流程
差错处理池设计
java
@Entity
@Table(name = "reconciliation_error_pool")
public class ErrorPoolRecord {
@Id
private Long id;
private String orderNo;
private String diffType; // 差异类型
private BigDecimal platformAmount;
private BigDecimal channelAmount;
private String channel;
private String status; // UNPROCESSED / PROCESSING / RESOLVED / CLOSED
private String assignee; // 处理人
private String remark;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}人工介入流程
差 错 处 理 池
│
├── 每日对账报告推送 → 财务/运营人员
│
├── 人工认领差错单
│ ├── 查询平台订单详情
│ ├── 查询渠道交易详情
│ ├── 对比分析确认原因
│ └── 执行处理方案
│
├── 处理方案
│ ├── 长款 → 确认资金到账 → 补单 → 状态变更为 RESOLVED
│ ├── 短款 → 联系渠道客服 → 线下退款 → 关闭
│ ├── 金额不一致 → 以渠道金额为准 → 调账 → 记录原因
│ └── 无单 → 确认为非本系统订单 → 关闭
│
└── 处理完成 → 归档差错处理 API
java
@RestController
@RequestMapping("/api/reconciliation/error-pool")
public class ErrorPoolController {
@GetMapping("/list")
public Result<PageResult<ErrorPoolRecord>> list(ErrorPoolQuery query) {
// 分页查询差错记录,支持按状态/渠道/时间范围筛选
}
@PostMapping("/claim")
public Result<Void> claim(@RequestParam Long id, @RequestParam String assignee) {
// 人工认领差错单,状态变为 PROCESSING
}
@PostMapping("/resolve")
public Result<Void> resolve(@RequestBody ErrorResolveRequest request) {
// 提交处理结果,包括处理方式、备注、凭证附件
}
@PostMapping("/close")
public Result<Void> close(@RequestParam Long id, @RequestParam String reason) {
// 关闭差错单(无需处理的情况)
}
}2. 搜索系统
2.1 ES 商品搜索架构
索引设计
Settings 配置
json
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"max_result_window": 10000,
"analysis": {
"analyzer": {
"ik_smart_analyzer": {
"type": "custom",
"tokenizer": "ik_smart"
},
"ik_max_word_analyzer": {
"type": "custom",
"tokenizer": "ik_max_word"
},
"pinyin_analyzer": {
"type": "custom",
"tokenizer": "pinyin_tokenizer"
}
},
"tokenizer": {
"pinyin_tokenizer": {
"type": "pinyin",
"keep_first_letter": true,
"keep_separate_first_letter": false,
"keep_full_pinyin": true,
"keep_original": true,
"limit_first_letter_length": 16,
"lowercase": true,
"remove_duplicated_term": true
}
}
}
}
}Mapping 配置
json
{
"mappings": {
"properties": {
"skuId": {
"type": "keyword"
},
"spuId": {
"type": "keyword"
},
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart",
"fields": {
"pinyin": {
"type": "text",
"analyzer": "pinyin_analyzer"
},
"keyword": {
"type": "keyword"
}
}
},
"categoryName": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"analyzer": "ik_smart"
}
}
},
"brandName": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"analyzer": "ik_smart"
}
}
},
"price": {
"type": "double"
},
"saleCount": {
"type": "integer"
},
"rating": {
"type": "float"
},
"reviewCount": {
"type": "integer"
},
"stock": {
"type": "integer"
},
"status": {
"type": "keyword"
},
"tags": {
"type": "keyword"
},
"attributes": {
"type": "nested",
"properties": {
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
},
"categoryId": {
"type": "long"
},
"brandId": {
"type": "long"
},
"shopId": {
"type": "long"
},
"createTime": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||epoch_millis"
},
"updateTime": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||epoch_millis"
}
}
}
}2.2 中文分词器
IK 分词器配置
IK 分词器支持两种分词模式:
| 模式 | 粒度 | 适用场景 |
|---|---|---|
ik_max_word | 最细粒度拆分 | 索引阶段:尽可能多的分词,提高召回率 |
ik_smart | 最粗粒度拆分 | 搜索阶段:精确匹配,提高精准率 |
IK 自定义词库
xml
<!-- IKAnalyzer.cfg.xml -->
<properties>
<entry key="ext_dict">custom/mydict.dic</entry>
<entry key="ext_stopwords">custom/stopword.dic</entry>
</properties>自定义词库 mydict.dic 示例:
华为手机
智能冰箱
连衣裙
冲锋衣
九牧王
格力空调Elasticsearch 拼音分词器
拼音分词器用于处理用户输入拼音的搜索场景,配合 IK 分词器实现混合搜索。
json
{
"analyzer": {
"ik_pinyin_analyzer": {
"type": "custom",
"tokenizer": "ik_smart",
"filter": ["pinyin_filter"]
}
},
"filter": {
"pinyin_filter": {
"type": "pinyin",
"keep_first_letter": true,
"keep_full_pinyin": true,
"keep_original": true,
"remove_duplicated_term": true
}
}
}2.3 多字段搜索
基础搜索
java
@Service
public class ProductSearchService {
@Autowired
private RestHighLevelClient client;
/**
* 多字段组合搜索
*/
public SearchResult search(ProductSearchRequest request) {
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
// 关键词多字段匹配
if (StringUtils.isNotBlank(request.getKeyword())) {
// 标题字段(核心匹配字段,权重最高)
// 分类名称、品牌名称(辅助匹配)
// 拼音字段(拼音纠错兜底)
boolQuery.must(QueryBuilders.multiMatchQuery(request.getKeyword(),
"title^3", // 标题权重 3 倍
"title.pinyin^1.5", // 标题拼音权重 1.5 倍
"brandName^2", // 品牌名称权重 2 倍
"brandName.text",
"categoryName^1.5",
"categoryName.text"
));
}
// 过滤条件
if (request.getCategoryId() != null) {
boolQuery.filter(QueryBuilders.termQuery("categoryId", request.getCategoryId()));
}
if (request.getBrandId() != null) {
boolQuery.filter(QueryBuilders.termQuery("brandId", request.getBrandId()));
}
if (request.getMinPrice() != null || request.getMaxPrice() != null) {
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("price");
if (request.getMinPrice() != null) rangeQuery.gte(request.getMinPrice());
if (request.getMaxPrice() != null) rangeQuery.lte(request.getMaxPrice());
boolQuery.filter(rangeQuery);
}
if (CollectionUtils.isNotEmpty(request.getTags())) {
boolQuery.filter(QueryBuilders.termsQuery("tags", request.getTags()));
}
// 属性筛选(nested 查询)
if (CollectionUtils.isNotEmpty(request.getAttributes())) {
for (AttributeFilter attr : request.getAttributes()) {
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery(
"attributes",
QueryBuilders.boolQuery()
.must(QueryBuilders.termQuery("attributes.attrName", attr.getName()))
.must(QueryBuilders.termQuery("attributes.attrValue", attr.getValue())),
ScoreMode.None
);
boolQuery.filter(nestedQuery);
}
}
// 构建搜索请求
SearchSourceBuilder sourceBuilder = SearchSourceBuilder.searchSource()
.query(boolQuery)
.from(request.getPageNum() * request.getPageSize())
.size(request.getPageSize())
.sort(buildSort(request.getSortField(), request.getSortOrder()))
.aggregation(buildAggregations());
// 执行搜索
SearchRequest searchRequest = new SearchRequest("products");
searchRequest.source(sourceBuilder);
SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT);
return buildResult(response);
}
}function_score 自定义评分
java
/**
* 综合排序:结合文本相关性 + 销量 + 评分 + 时间衰减
*/
public QueryBuilder buildRankedQuery(String keyword) {
FunctionScoreQueryBuilder.FilterFunctionBuilder[] functions = new FunctionScoreQueryBuilder.FilterFunctionBuilder[] {
// 销量因子:销量越高,得分越高
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
ScoreFunctionBuilders.fieldValueFactorFunction("saleCount")
.factor(0.1f)
.modifier(FieldValueFactorFunction.Modifier.LN1P)
),
// 评分因子:评分越高,得分越高
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
ScoreFunctionBuilders.fieldValueFactorFunction("rating")
.factor(0.2f)
.modifier(FieldValueFactorFunction.Modifier.NONE)
),
// 时间衰减:新上架商品加权
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
ScoreFunctionBuilders.gaussDecayFunction("createTime", "now", "30d")
.setDecay(0.5f)
)
};
return QueryBuilders.functionScoreQuery(
QueryBuilders.multiMatchQuery(keyword, "title^3", "title.pinyin", "brandName^2"),
functions
).scoreMode(ScoreMode.SUM).boostMode(CombineFunction.MULTIPLY);
}2.4 搜索排序策略
| 排序方式 | 实现 | 适用场景 |
|---|---|---|
| 综合排序 | function_score 综合文本相关性 + 销量 + 评分 + 时间 | 默认排序 |
| 销量排序 | field_value_factor 按 saleCount 降序 | 用户按销量浏览 |
| 价格排序 | 按 price 字段升序或降序 | 用户比价场景 |
| 评价排序 | 按 rating 降序,辅以 reviewCount | 用户关注口碑 |
java
public SortBuilder<?> buildSort(String sortField, String sortOrder) {
if (StringUtils.isBlank(sortField) || "comprehensive".equals(sortField)) {
// 综合排序使用 _score(function_score 已处理)
return SortBuilders.scoreSort().order(SortOrder.DESC);
}
SortOrder order = "desc".equalsIgnoreCase(sortOrder) ? SortOrder.DESC : SortOrder.ASC;
switch (sortField) {
case "price":
return SortBuilders.fieldSort("price").order(order);
case "saleCount":
return SortBuilders.fieldSort("saleCount").order(SortOrder.DESC);
case "rating":
return SortBuilders.fieldSort("rating").order(SortOrder.DESC);
default:
return SortBuilders.scoreSort().order(SortOrder.DESC);
}
}2.5 搜索过滤与聚合
聚合查询(品牌/分类/属性筛选)
java
private List<AggregationBuilder> buildAggregations() {
// 品牌聚合
TermsAggregationBuilder brandAgg = AggregationBuilders.terms("brand_agg")
.field("brandId")
.size(50)
.subAggregation(AggregationBuilders.topHits("brand_top_hits")
.size(1)
.fetchSource(new String[]{"brandName"}, null));
// 分类聚合
TermsAggregationBuilder categoryAgg = AggregationBuilders.terms("category_agg")
.field("categoryId")
.size(50)
.subAggregation(AggregationBuilders.topHits("category_top_hits")
.size(1)
.fetchSource(new String[]{"categoryName"}, null));
// 价格区间聚合(用户自定义范围)
RangeAggregationBuilder priceAgg = AggregationBuilders.range("price_agg")
.field("price")
.addUnboundedTo(100)
.addRange(100, 300)
.addRange(300, 500)
.addRange(500, 1000)
.addUnboundedFrom(1000);
// 属性聚合(nested)
NestedAggregationBuilder attrAgg = AggregationBuilders.nested("attr_agg", "attributes")
.subAggregation(AggregationBuilders.terms("attr_name_agg")
.field("attributes.attrName")
.size(50)
.subAggregation(AggregationBuilders.terms("attr_value_agg")
.field("attributes.attrValue")
.size(100)));
return Arrays.asList(brandAgg, categoryAgg, priceAgg, attrAgg);
}前端过滤数据结构
json
{
"filterGroups": [
{
"name": "品牌",
"type": "brand",
"options": [
{"id": 1, "name": "华为", "count": 120},
{"id": 2, "name": "小米", "count": 85},
{"id": 3, "name": "苹果", "count": 63}
]
},
{
"name": "分类",
"type": "category",
"options": [
{"id": 101, "name": "手机", "count": 200},
{"id": 102, "name": "平板电脑", "count": 68}
]
},
{
"name": "价格区间",
"type": "price",
"options": [
{"from": null, "to": 100, "label": "100元以下", "count": 45},
{"from": 100, "to": 300, "label": "100-300元", "count": 120},
{"from": 300, "to": 500, "label": "300-500元", "count": 89}
]
},
{
"name": "屏幕尺寸",
"type": "attribute",
"options": [
{"value": "6.1英寸", "count": 56},
{"value": "6.7英寸", "count": 42}
]
}
]
}2.6 搜索兜底策略
拼音纠错
当原关键词搜索结果为空或数量过少时,触发拼音纠错兜底。
java
public SearchResult searchWithFallback(ProductSearchRequest request) {
// 1. 正常搜索
SearchResult result = search(request);
// 2. 如果结果为空,触发拼音纠错搜索
if (result.getTotal() == 0 && StringUtils.isNotBlank(request.getKeyword())) {
// 修改关键词为拼音字段搜索
ProductSearchRequest pinyinRequest = request.clone();
pinyinRequest.setPinyinMode(true); // 切换到拼音字段
// 使用 match_phrase_prefix 进行拼音前缀匹配
BoolQueryBuilder fallbackQuery = QueryBuilders.boolQuery();
fallbackQuery.must(QueryBuilders.matchQuery("title.pinyin", request.getKeyword()));
// 执行拼音兜底搜索
result = searchByQuery(fallbackQuery, request);
}
return result;
}同义词扩展
配置同义词词典,扩展搜索关键词,提高召回率。
json
{
"filter": {
"synonym_filter": {
"type": "synonym",
"synonyms_path": "analysis/synonym.txt",
"updateable": true
}
}
}synonym.txt 内容示例:
# 同义词配置(=> 表示单向扩展,逗号表示双向等价)
手机, 电话, 移动电话 => 手机
电脑, 计算机, PC => 电脑
笔记本, 笔记本电脑, 便携电脑 => 笔记本
连衣裙, 裙子, 长裙 => 连衣裙
电视, 电视机, 液晶电视 => 电视兜底策略流程
用户输入关键词
│
├── Step 1: 原始搜索(ik_smart 分词 + 多字段匹配)
│ ├── 结果 >= 阈值 → 返回结果
│ └── 结果 < 阈值 → 进入兜底
│
├── Step 2: 同义词扩展
│ ├── 加载同义词表,扩展查询词
│ ├── OR 条件加入同义词,重新搜索
│ └── 结果 >= 阈值 → 返回结果(标注"包含相似结果")
│
├── Step 3: 拼音纠错
│ ├── 使用 pinyin 分词器重新搜索
│ ├── 模糊匹配(fuzziness = AUTO)
│ └── 返回结果或提示"您是不是想找:xxx"
│
└── Step 4: 完全无结果
└── 返回空结果 + 推荐热搜词/热门商品3. 推荐系统
3.1 推荐架构
推荐系统采用经典的"召回 -> 排序 -> 重排"三层级架构。
整体架构
用户请求
│
├── 召回阶段 (Recall)
│ ├── 协同过滤召回(UserCF / ItemCF)
│ ├── 热度召回(Hot Products)
│ ├── 实时行为召回(Real-time)
│ └── 多路召回合并
│
├── 排序阶段 (Ranking)
│ ├── 特征工程(用户特征、物品特征、上下文特征)
│ ├── CTR/CVR 预估模型
│ └── 得分排序
│
└── 重排阶段 (Re-ranking)
├── 多样性打散(类目/品牌去重)
├── 去已购/去已推荐
├── 运营策略插队(置顶/强插)
└── 最终 TopN 输出推荐流程代码
java
@Service
public class RecommendService {
@Autowired
private List<RecallStrategy> recallStrategies;
@Autowired
private RankingService rankingService;
@Autowired
private ReRankService reRankService;
public RecommendResult recommend(Long userId, RecommendContext context) {
// 1. 多路召回
List<RecallItem> allRecalls = new ArrayList<>();
for (RecallStrategy strategy : recallStrategies) {
List<RecallItem> items = strategy.recall(userId, context);
allRecalls.addAll(items);
}
// 去重(相同商品保留最高分)
Map<Long, RecallItem> dedupMap = new LinkedHashMap<>();
for (RecallItem item : allRecalls) {
dedupMap.merge(item.getSkuId(), item,
(a, b) -> a.getScore() >= b.getScore() ? a : b);
}
List<RecallItem> merged = new ArrayList<>(dedupMap.values());
// 2. 排序
List<RankedItem> ranked = rankingService.rank(userId, merged);
// 3. 重排
List<Long> finalResult = reRankService.reRank(userId, ranked, context);
return new RecommendResult(finalResult);
}
}3.2 协同过滤
基于用户的协同过滤(UserCF)
核心思想:找到与目标用户兴趣相似的用户群体,推荐这些用户喜欢的商品。
相似度计算
java
@Component
public class UserBasedCF implements RecallStrategy {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String USER_SIM_PREFIX = "rec:user:sim:";
/**
* 计算用户之间的相似度(余弦相似度)
* 用户画像向量:[类目1_偏好, 类目2_偏好, ..., 价格敏感度, 品牌偏好]
*/
public double cosineSimilarity(Map<Long, Double> user1Vector, Map<Long, Double> user2Vector) {
// 获取交集商品
Set<Long> intersection = new HashSet<>(user1Vector.keySet());
intersection.retainAll(user2Vector.keySet());
if (intersection.isEmpty()) {
return 0.0;
}
double dotProduct = 0.0;
double norm1 = 0.0;
double norm2 = 0.0;
for (Long itemId : intersection) {
dotProduct += user1Vector.get(itemId) * user2Vector.get(itemId);
}
for (double val : user1Vector.values()) {
norm1 += val * val;
}
for (double val : user2Vector.values()) {
norm2 += val * val;
}
if (norm1 == 0.0 || norm2 == 0.0) {
return 0.0;
}
return dotProduct / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
/**
* 基于用户的召回
*/
@Override
public List<RecallItem> recall(Long userId, RecommendContext context) {
// 1. 获取与目标用户最相似的 K 个用户
String key = USER_SIM_PREFIX + userId;
List<Long> similarUsers = redisTemplate.opsForZSet()
.reverseRangeByScore(key, 0, Double.MAX_VALUE, 0, 20);
// 2. 获取相似用户购买/收藏的商品
Map<Long, Double> candidateScores = new HashMap<>();
for (Long similarUser : similarUsers) {
List<UserBehavior> behaviors = behaviorService.queryUserBehaviors(similarUser, 30);
for (UserBehavior behavior : behaviors) {
double weight = getBehaviorWeight(behavior.getType()); // 购买=3, 收藏=2, 浏览=1
candidateScores.merge(behavior.getSkuId(),
weight, Double::sum);
}
}
// 3. 过滤掉目标用户已购买商品
Set<Long> purchased = behaviorService.queryPurchasedSkuIds(userId);
candidateScores.keySet().removeAll(purchased);
// 4. 返回 TopN
return candidateScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(context.getRecallSize())
.map(e -> new RecallItem(e.getKey(), e.getValue(), "user_cf"))
.collect(Collectors.toList());
}
}基于物品的协同过滤(ItemCF)
核心思想:根据用户历史行为,推荐与已交互商品相似的商品。
java
@Component
public class ItemBasedCF implements RecallStrategy {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String ITEM_SIM_PREFIX = "rec:item:sim:";
@Override
public List<RecallItem> recall(Long userId, RecommendContext context) {
// 1. 获取用户最近交互的商品列表
List<UserBehavior> recentBehaviors = behaviorService.queryUserBehaviors(userId, 10);
// 2. 对于每个交互商品,找到最相似的 TopK 商品
Map<Long, Double> candidateScores = new HashMap<>();
for (UserBehavior behavior : recentBehaviors) {
String key = ITEM_SIM_PREFIX + behavior.getSkuId();
Map<Object, Object> similarItems = redisTemplate.opsForHash().entries(key);
double behaviorWeight = getBehaviorWeight(behavior.getType());
for (Map.Entry<Object, Object> entry : similarItems.entrySet()) {
Long similarSkuId = Long.parseLong(entry.getKey().toString());
double similarity = Double.parseDouble(entry.getValue().toString());
candidateScores.merge(similarSkuId,
behaviorWeight * similarity, Double::sum);
}
}
// 3. 过滤已购
Set<Long> purchased = behaviorService.queryPurchasedSkuIds(userId);
candidateScores.keySet().removeAll(purchased);
// 4. 返回 TopN
return candidateScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(context.getRecallSize())
.map(e -> new RecallItem(e.getKey(), e.getValue(), "item_cf"))
.collect(Collectors.toList());
}
}3.3 热度推荐
热度推荐为新用户或行为稀疏用户提供冷启动推荐,基于时间衰减和销量加权计算综合热度分。
热度分计算公式
hotScore = log(saleCount + 1) * 0.4
+ avgRating * 0.2
+ reviewCount * 0.1
+ timeDecay * 0.3
timeDecay = 1 / (1 + α * daysSincePublished)
α: 时间衰减因子(建议 0.01~0.05)热度推荐实现
java
@Component
public class HotProductRecall implements RecallStrategy {
@Override
public List<RecallItem> recall(Long userId, RecommendContext context) {
// 从 Redis 中读取预计算的热度排行榜
Set<ZSetOperations.TypedTuple<Object>> hotProducts =
redisTemplate.opsForZSet()
.reverseRangeWithScores("rec:hot:products", 0, context.getRecallSize());
return hotProducts.stream()
.map(tuple -> new RecallItem(
Long.parseLong(tuple.getValue().toString()),
tuple.getScore(),
"hot"
))
.collect(Collectors.toList());
}
}热度分定时计算
java
@Component
public class HotScoreCalculator {
/**
* 每 30 分钟刷新一次热度榜
*/
@Scheduled(fixedRate = 30 * 60 * 1000)
public void calculateHotScores() {
// 1. 查询近 7 天的商品销售数据
List<ProductStats> statsList = statsService.queryRecentStats(7);
// 2. 计算每条商品的热度分
Map<String, Double> hotScores = new HashMap<>();
for (ProductStats stats : statsList) {
double score = calculate(stats);
hotScores.put(String.valueOf(stats.getSkuId()), score);
}
// 3. 写入 Redis ZSet
String key = "rec:hot:products";
redisTemplate.delete(key);
redisTemplate.opsForZSet().add(key, hotScores);
// 4. 设置过期时间 1 小时
redisTemplate.expire(key, 1, TimeUnit.HOURS);
}
private double calculate(ProductStats stats) {
long daysSincePublished = Duration.between(
stats.getPublishTime(), LocalDateTime.now()).toDays();
double timeDecay = 1.0 / (1 + 0.02 * daysSincePublished);
return Math.log(stats.getSaleCount() + 1) * 0.4
+ stats.getAvgRating() * 0.2
+ Math.log(stats.getReviewCount() + 1) * 0.1
+ timeDecay * 0.3;
}
}3.4 实时推荐
实时推荐基于用户实时行为数据,通过 Kafka -> Flink -> Redis 链路实现秒级推荐更新。
数据流架构
用户实时行为(点击/加购/收藏/下单)
│
▼
Kafka Topic: user_behavior
│
▼
Flink 实时计算作业
├── 行为聚合(滑动窗口 5min)
├── 实时相似度计算
├── 规则匹配(购买了 A 的用户也看了 B)
└── 实时热门更新
│
▼
Redis 实时推荐结果
│
▼
API 服务读取实时推荐Flink 实时推荐作业
java
public class RealTimeRecommendJob {
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(4);
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);
// 1. 从 Kafka 消费用户行为数据
Properties kafkaProps = new Properties();
kafkaProps.setProperty("bootstrap.servers", "localhost:9092");
kafkaProps.setProperty("group.id", "recommend-group");
DataStream<UserBehaviorEvent> behaviorStream = env
.addSource(new FlinkKafkaConsumer<>("user_behavior",
new JSONDeserializationSchema(), kafkaProps));
// 2. 滑动窗口聚合(每 1 分钟统计最近 5 分钟的行为)
DataStream<BehaviorAggregation> aggregated = behaviorStream
.keyBy(event -> event.getUserId() + "_" + event.getSkuId())
.window(SlidingProcessingTimeWindows.of(Time.minutes(5), Time.minutes(1)))
.aggregate(new BehaviorAggregator());
// 3. 实时热度计算
DataStream<RealTimeHotItem> hotItems = behaviorStream
.filter(event -> event.getType() == BehaviorType.VIEW
|| event.getType() == BehaviorType.CART)
.keyBy(UserBehaviorEvent::getSkuId)
.window(SlidingProcessingTimeWindows.of(Time.minutes(10), Time.minutes(1)))
.aggregate(new HotItemAggregator());
// 4. 写入 Redis
aggregated.addSink(new RedisSink<>(new RedisMapper<BehaviorAggregation>() {
@Override
public String getKeyFromData(BehaviorAggregation data) {
return "rec:realtime:user:" + data.getUserId();
}
@Override
public void insert(RedisTransaction transaction, BehaviorAggregation data) {
// 使用 Redis ZSet 存储实时候选商品,行为分值作为 score
double score = data.getCartCount() * 3 + data.getFavoriteCount() * 2 + data.getViewCount() * 0.5;
transaction.zadd(getKeyFromData(data), String.valueOf(data.getSkuId()), score);
// 限制 ZSet 大小
transaction.zremrangeByRank(getKeyFromData(data), 0, -201);
// 设置 TTL 30 分钟
transaction.expire(getKeyFromData(data), 1800);
}
}));
// 5. 实时热门写入 Redis
hotItems.addSink(new RedisSink<>(new RedisMapper<RealTimeHotItem>() {
@Override
public String getKeyFromData(RealTimeHotItem data) {
return "rec:hot:realtime";
}
@Override
public void insert(RedisTransaction transaction, RealTimeHotItem data) {
transaction.zadd("rec:hot:realtime", String.valueOf(data.getSkuId()), data.getScore());
transaction.expire("rec:hot:realtime", 600);
}
}));
env.execute("RealTimeRecommendJob");
}
}实时推荐服务
java
@Service
public class RealTimeRecommendService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public List<Long> getRealTimeRecommend(Long userId, int size) {
String key = "rec:realtime:user:" + userId;
Set<Object> skuIds = redisTemplate.opsForZSet()
.reverseRange(key, 0, size - 1);
return skuIds.stream()
.map(id -> Long.parseLong(id.toString()))
.collect(Collectors.toList());
}
}4. 评价系统
4.1 评价数据模型
数据库表设计
sql
CREATE TABLE `product_review` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_id` BIGINT NOT NULL COMMENT '订单ID',
`sku_id` BIGINT NOT NULL COMMENT '商品SKU ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`rating` TINYINT NOT NULL COMMENT '评分(1-5星)',
`content` VARCHAR(2000) DEFAULT NULL COMMENT '评价内容',
`images` TEXT DEFAULT NULL COMMENT '晒图列表(JSON数组存储图片URL)',
`extra_info` TEXT DEFAULT NULL COMMENT '扩展信息(JSON,存储规格/属性等)',
`tags` VARCHAR(500) DEFAULT NULL COMMENT '评价标签(逗号分隔或JSON数组)',
`is_anonymous` TINYINT DEFAULT 0 COMMENT '是否匿名(0-否,1-是)',
`is_top` TINYINT DEFAULT 0 COMMENT '是否置顶',
`status` TINYINT DEFAULT 0 COMMENT '状态(0-待审核,1-已通过,2-已驳回)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_sku_id` (`sku_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_rating` (`rating`),
KEY `idx_create_time` (`create_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品评价表';
CREATE TABLE `product_review_reply` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`review_id` BIGINT NOT NULL COMMENT '评价ID',
`user_id` BIGINT NOT NULL COMMENT '回复用户ID',
`content` VARCHAR(2000) NOT NULL COMMENT '回复内容',
`type` TINYINT NOT NULL COMMENT '回复类型(1-商家回复,2-用户追评)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_review_id` (`review_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评价回复/追评表';Java 实体模型
java
@Data
@TableName("product_review")
public class ProductReview {
@TableId(type = IdType.AUTO)
private Long id;
private Long orderId;
private Long skuId;
private Long userId;
private Integer rating; // 评分 1-5
private String content;
private String images; // JSON 数组: ["url1","url2"]
private String extraInfo; // JSON: {"spec":"红色","size":"L","deliveryTime":"2天"}
private String tags; // JSON 数组: ["质量好","发货快"]
private Integer isAnonymous;
private Integer isTop;
private Integer status; // 0-待审核, 1-已通过, 2-已驳回
private LocalDateTime createTime;
private LocalDateTime updateTime;
}4.2 评价标签体系
评价标签系统用于自动提取评价关键词,辅助用户快速了解商品口碑。
标签分类
java
public enum ReviewTag {
// 质量相关
GOOD_QUALITY("质量好", TagCategory.QUALITY),
POOR_QUALITY("质量差", TagCategory.QUALITY),
DURABLE("耐用好", TagCategory.QUALITY),
// 外观相关
GOOD_LOOKS("外观好看", TagCategory.APPEARANCE),
COLOR_OK("颜色正", TagCategory.APPEARANCE),
// 物流相关
FAST_DELIVERY("发货快", TagCategory.LOGISTICS),
GOOD_PACKAGING("包装好", TagCategory.LOGISTICS),
// 服务相关
NICE_SERVICE("服务态度好", TagCategory.SERVICE),
EASY_RETURN("退换方便", TagCategory.SERVICE),
// 性价比
GOOD_VALUE("性价比高", TagCategory.VALUE),
EXPENSIVE("价格偏高", TagCategory.VALUE);
private final String displayName;
private final TagCategory category;
}标签自动匹配
java
@Component
public class ReviewTagMatcher {
/**
* 根据评价内容自动匹配标签
*/
public List<String> matchTags(String content, int rating) {
List<String> matchedTags = new ArrayList<>();
// 基于关键词匹配
Map<String, String> keywordTagMap = new HashMap<>();
keywordTagMap.put("质量好", "质量好");
keywordTagMap.put("质量不错", "质量好");
keywordTagMap.put("很耐用", "耐用好");
keywordTagMap.put("发货快", "发货快");
keywordTagMap.put("物流很快", "发货快");
keywordTagMap.put("性价比高", "性价比高");
keywordTagMap.put("外观好看", "外观好看");
keywordTagMap.put("服务态度好", "服务态度好");
keywordTagMap.put("包装很好", "包装好");
keywordTagMap.put("很满意", "好评");
for (Map.Entry<String, String> entry : keywordTagMap.entrySet()) {
if (content.contains(entry.getKey())) {
matchedTags.add(entry.getValue());
}
}
// 基于评分补充标签
if (rating >= 4 && !matchedTags.contains("好评")) {
matchedTags.add("好评");
} else if (rating <= 2 && matchedTags.isEmpty()) {
matchedTags.add("差评");
}
return matchedTags;
}
}4.3 晒图与追评
晒图功能
评价支持上传多张图片,图片通过对象存储服务(OSS)管理。
java
@Service
public class ReviewImageService {
@Autowired
private OssService ossService;
/**
* 上传评价图片,生成缩略图并返回 URL
*/
public List<String> uploadImages(MultipartFile[] files) {
List<String> urls = new ArrayList<>();
for (MultipartFile file : files) {
// 校验图片大小(单张不超过 5MB)
if (file.getSize() > 5 * 1024 * 1024) {
throw new BusinessException("单张图片不能超过 5MB");
}
// 校验图片格式
String contentType = file.getContentType();
if (!Arrays.asList("image/jpeg", "image/png", "image/webp").contains(contentType)) {
throw new BusinessException("仅支持 JPG/PNG/WebP 格式");
}
// 上传 OSS
String url = ossService.upload(file, "review/images/");
urls.add(url);
}
return urls;
}
}追评功能
追评允许用户在初次评价后补充评价内容,记录在 product_review_reply 表中。
java
@Service
public class ReviewFollowUpService {
@Autowired
private ReviewReplyMapper replyMapper;
@Autowired
private ReviewMapper reviewMapper;
/**
* 用户追评
*/
public void followUpReview(FollowUpRequest request) {
// 1. 校验评价是否存在且已通过
ProductReview review = reviewMapper.selectById(request.getReviewId());
if (review == null || review.getStatus() != 1) {
throw new BusinessException("评价不存在或未通过审核");
}
// 2. 校验是否已追评(每个评价仅允许追评一次)
LambdaQueryWrapper<ProductReviewReply> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ProductReviewReply::getReviewId, request.getReviewId())
.eq(ProductReviewReply::getType, 2); // 2-追评
if (replyMapper.selectCount(wrapper) > 0) {
throw new BusinessException("已追评过,不可重复追评");
}
// 3. 校验追评时间(下单后 30 天内可追评)
LocalDateTime orderTime = orderService.getOrderTime(review.getOrderId());
if (Duration.between(orderTime, LocalDateTime.now()).toDays() > 30) {
throw new BusinessException("已超过追评期限(下单后 30 天)");
}
// 4. 保存追评
ProductReviewReply reply = new ProductReviewReply();
reply.setReviewId(request.getReviewId());
reply.setUserId(review.getUserId());
reply.setContent(request.getContent());
reply.setType(2); // 追评
replyMapper.insert(reply);
// 5. 更新评价的追评标记
review.setExtraInfo(updateExtraInfoForFollowUp(review.getExtraInfo()));
reviewMapper.updateById(review);
}
}4.4 恶意评价识别
恶意评价识别采用多层级检测策略:敏感词过滤 + 异常评分检测 + 行为特征分析。
敏感词过滤
java
@Component
public class SensitiveWordFilter {
private final Set<String> sensitiveWords = new HashSet<>();
private final Map<Character, Set<Character>> similarCharMap = new HashMap<>();
@PostConstruct
public void init() {
// 加载敏感词库(从数据库/配置文件加载)
sensitiveWords.addAll(loadSensitiveWords());
// 初始化相似字符映射(用于变形词识别)
similarCharMap.put('0', new HashSet<>(Arrays.asList('o', 'O')));
similarCharMap.put('1', new HashSet<>(Arrays.asList('l', 'I')));
// ... 更多映射
}
/**
* 检测内容是否包含敏感词
*/
public SensitiveCheckResult check(String content) {
List<String> foundWords = new ArrayList<>();
String normalized = normalizeContent(content);
for (String sensitiveWord : sensitiveWords) {
if (normalized.contains(sensitiveWord)) {
foundWords.add(sensitiveWord);
}
}
return new SensitiveCheckResult(
!foundWords.isEmpty(),
foundWords,
calculateSensitiveScore(foundWords)
);
}
/**
* 内容归一化:统一大小写、替换形近字、去空格
*/
private String normalizeContent(String content) {
String normalized = content.toLowerCase()
.replaceAll("\\s+", "")
.replaceAll("[^\\u4e00-\\u9fa5a-zA-Z0-9]", "");
// 替换形近字
StringBuilder sb = new StringBuilder();
for (char c : normalized.toCharArray()) {
if (similarCharMap.containsKey(c)) {
sb.append(c); // 保留原字符
} else {
sb.append(c);
}
}
return sb.toString();
}
}异常评分检测
java
@Component
public class AnomalyRatingDetector {
/**
* 检测异常评分模式
*/
public AnomalyCheckResult checkAnomaly(Long userId, Long skuId, int rating, String content) {
List<AnomalyReason> reasons = new ArrayList<>();
// 1. 评分与内容情感不一致
SentimentResult sentiment = analyzeSentiment(content);
if (rating >= 4 && sentiment.getScore() < -0.5) {
// 高分但负面情绪 -> 可能存在恶意刷好评
reasons.add(AnomalyReason.POSITIVE_RATING_NEGATIVE_CONTENT);
}
if (rating <= 2 && sentiment.getScore() > 0.5) {
// 低分但正面情绪 -> 可能存在恶意差评
reasons.add(AnomalyReason.NEGATIVE_RATING_POSITIVE_CONTENT);
}
// 2. 连续差评检测
int recentLowRatings = reviewMapper.countRecentLowRatings(userId, 7);
if (recentLowRatings >= 3) {
reasons.add(AnomalyReason.CONSECUTIVE_LOW_RATINGS);
}
// 3. 短时间内大量评价(刷评嫌疑)
int recentReviews = reviewMapper.countRecentReviews(userId, 1);
if (recentReviews > 10) {
reasons.add(AnomalyReason.HIGH_FREQUENCY_REVIEWS);
}
// 4. 评价内容重复度检测
String similarContent = reviewMapper.findSimilarRecentContent(content, userId);
if (similarContent != null) {
reasons.add(AnomalyReason.DUPLICATE_CONTENT);
}
// 5. 同一 IP 多个用户差评
String ipAddress = IpUtil.getCurrentRequestIp();
int sameIpLowRatings = reviewMapper.countLowRatingsByIp(ipAddress, skuId, 1);
if (sameIpLowRatings >= 5) {
reasons.add(AnomalyReason.SAME_IP_LOW_RATINGS);
}
return new AnomalyCheckResult(!reasons.isEmpty(), reasons);
}
/**
* 简单情感分析(基于情感词典)
*/
private SentimentResult analyzeSentiment(String content) {
// 积极词库
Set<String> positiveWords = new HashSet<>(Arrays.asList(
"好", "棒", "满意", "喜欢", "推荐", "不错", "值得", "赞", "好评", "超值"
));
// 消极词库
Set<String> negativeWords = new HashSet<>(Arrays.asList(
"差", "烂", "垃圾", "后悔", "失望", "不值", "差评", "坑", "假", "劣质"
));
int positiveCount = 0;
int negativeCount = 0;
for (String word : positiveWords) {
if (content.contains(word)) positiveCount++;
}
for (String word : negativeWords) {
if (content.contains(word)) negativeCount++;
}
double total = positiveCount + negativeCount;
if (total == 0) return new SentimentResult(0.0);
return new SentimentResult((positiveCount - negativeCount) / total);
}
}评价审核流程
java
@Service
public class ReviewAuditService {
@Autowired
private SensitiveWordFilter sensitiveWordFilter;
@Autowired
private AnomalyRatingDetector anomalyDetector;
/**
* 提交评价并自动审核
*/
@Transactional
public ReviewResult submitReview(CreateReviewRequest request) {
// 1. 基础校验
validateReviewRequest(request);
// 2. 敏感词检测
SensitiveCheckResult sensitiveCheck = sensitiveWordFilter.check(request.getContent());
if (sensitiveCheck.isHit()) {
// 命中敏感词 -> 自动驳回并记录
reviewLogService.logAudit(request.getUserId(), request.getSkuId(),
AuditResult.REJECTED, "命中敏感词: " + sensitiveCheck.getFoundWords());
return ReviewResult.rejected("评价内容包含违规词汇");
}
// 3. 异常评分检测
AnomalyCheckResult anomalyCheck = anomalyDetector.checkAnomaly(
request.getUserId(), request.getSkuId(),
request.getRating(), request.getContent());
// 4. 根据检测结果决定审核方式
AuditDecision decision;
if (sensitiveCheck.getScore() > 0.8 || anomalyCheck.isAnomaly()) {
// 高风险:人工审核
decision = AuditDecision.MANUAL_REVIEW;
} else if (sensitiveCheck.getScore() > 0.3 || anomalyCheck.getReasons().size() >= 2) {
// 中风险:先展示但标记,后续人工复查
decision = AuditDecision.AUTO_APPROVED_WITH_MARK;
} else {
// 低风险:自动通过
decision = AuditDecision.AUTO_APPROVED;
}
// 5. 保存评价
ProductReview review = buildReview(request, decision);
reviewMapper.insert(review);
// 6. 如需人工审核,写入审核任务队列
if (decision == AuditDecision.MANUAL_REVIEW) {
reviewAuditTaskService.pushTask(review.getId());
}
return ReviewResult.success(review.getId(), decision);
}
}恶意评价处理流程
用户提交评价
│
├── Step 1: 敏感词过滤
│ ├── 命中 → 自动驳回 + 记录日志
│ └── 未命中 → 继续
│
├── Step 2: 异常评分检测
│ ├── 评分/内容情感不一致检查
│ ├── 连续差评/高频评价检查
│ ├── 内容重复度检查
│ └── 同 IP 批量差评检查
│
├── Step 3: 审核决策
│ ├── 低风险 → 自动通过(立即展示)
│ ├── 中风险 → 自动通过 + 标记(后续人工复查)
│ └── 高风险 → 进入人工审核队列
│
└── Step 4: 人工审核
├── 审核通过(去除标记)
├── 审核驳回(隐藏评价 + 通知用户)
└── 用户申诉 → 二次审核人工审核后台
java
@RestController
@RequestMapping("/api/admin/review")
public class ReviewAdminController {
@GetMapping("/pending")
public Result<PageResult<ProductReview>> getPendingReviews(PageParam page) {
// 获取待人工审核的评价列表
LambdaQueryWrapper<ProductReview> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(ProductReview::getStatus, 0); // 0-待审核
wrapper.orderByAsc(ProductReview::getCreateTime);
return Result.success(reviewMapper.selectPage(page.toPage(), wrapper));
}
@PostMapping("/approve")
public Result<Void> approveReview(@RequestParam Long reviewId, @RequestParam String remark) {
ProductReview review = reviewMapper.selectById(reviewId);
review.setStatus(1); // 1-已通过
review.setExtraInfo(updateExtraInfo(remark));
reviewMapper.updateById(review);
// 记录操作日志
adminLogService.log("审核通过", reviewId, remark);
return Result.success();
}
@PostMapping("/reject")
public Result<Void> rejectReview(@RequestParam Long reviewId,
@RequestParam String reason,
@RequestParam(required = false) String remark) {
ProductReview review = reviewMapper.selectById(reviewId);
review.setStatus(2); // 2-已驳回
reviewMapper.updateById(review);
// 通知用户
notificationService.sendReviewRejected(review.getUserId(), reason);
return Result.success();
}
}