1303 lines
48 KiB
Markdown
1303 lines
48 KiB
Markdown
# 拼团交易系统
|
||
|
||
## 系统设计
|
||
|
||
### **功能流程**
|
||
|
||

|
||
|
||
|
||
|
||
### **库表设计**
|
||
|
||

|
||
|
||
- 首先,站在**运营**的角度,要为这次拼团配置对应的**拼团活动**。那么就会涉及到;给哪个渠道的**什么商品**ID配置拼团,这样用户在进入商品页就可以看到带有拼团商品的信息了。之后要考虑,这个拼团的商**品所提供的规则信息**,包括:折扣、起止时间、人数等。还要拿到折扣的一个**试算金额**。这个试算出来的金额,就是告诉用户,通过拼团可以拿到的最低价格。
|
||
- 之后,站在**用户**的角度,是参与拼团。首次**发起一个拼团**或者**参与已存在的拼团**进行数据的记录,达成拼团约定拼团人数后,开始进行**通知**。这个通知的设计站在平台角度可以提供回调,那么任何的系统也就都可以接入了。
|
||
- 另外,为了支持拼团库表,需要先根据业务规则把符合条件的用户 ID 写入 Redis,并为这批用户打上可配置的**人群标签**。创建拼团活动时,只需关联对应标签,即可让活动自动面向这部分用户生效,实现精准运营和差异化折扣。
|
||
- 那么,拼团活动表,为什么会把**折扣拆分**出来呢。因为这里的折扣**可能有多种**迭代到一个拼团上。比如,给一个商品添加了直减10元的优惠,又对符合的人群id的用户,额外打9折,这样就有了2个折扣迭代。所以拆分出来会更好维护。这是对常变的元素和稳定的元素进行设计的思考。
|
||
|
||
|
||
|
||
**(一)拼团配置表**
|
||
|
||
group_buy_activity 拼团活动
|
||
|
||
| 字段名 | 说明 |
|
||
| ---------------- | -------------------------------------------------------- |
|
||
| id | 自增ID |
|
||
| activity_id | 活动ID |
|
||
| source | 来源 |
|
||
| channel | 渠道 |
|
||
| goods_id | 商品ID |
|
||
| discount_id | 折扣ID |
|
||
| group_type | 成团方式【0自动成团(到时间后自动成团)、1达成目标成团】 |
|
||
| take_limit_count | 拼团次数限制 |
|
||
| target | 达成目标(3人单、5人单) |
|
||
| valid_time | 拼单时长(20分钟),未完成拼团则=》自动成功or失败 |
|
||
| status | 活动状态 (活动是否有效,运营可临时设置为失效) |
|
||
| start_time | 活动开始时间 |
|
||
| end_time | 活动结束时间 |
|
||
| tag_id | 人群标签规则标识 |
|
||
| tag_scope | 人群标签规则范围【多选;可见、参与】 |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
group_buy_discount 折扣配置
|
||
|
||
| 字段名 | 说明 |
|
||
| ------------- | --------------------------------- |
|
||
| id | 自增ID |
|
||
| discount_id | 折扣ID |
|
||
| discount_name | 折扣标题 |
|
||
| discount_desc | 折扣描述 |
|
||
| discount_type | 类型【base、tag】 |
|
||
| market_plan | 营销优惠计划【直减、满减、N元购】 |
|
||
| market_expr | 营销优惠表达式 |
|
||
| tag_id | 人群标签,特定优惠限定 |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
crowd_tags 人群标签
|
||
|
||
| 字段名 | 说明 |
|
||
| ----------- | ----------------------------- |
|
||
| id | 自增ID |
|
||
| tag_id | 标签ID |
|
||
| tag_name | 标签名称 |
|
||
| tag_desc | 标签描述 |
|
||
| statistics | 人群标签统计量 200\10万\100万 |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
crowd_tags_detail 人群标签明细(写入缓存)
|
||
|
||
| 字段名 | 说明 |
|
||
| ----------- | -------- |
|
||
| id | 自增ID |
|
||
| tag_id | 标签ID |
|
||
| user_id | 用户ID |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
crowd_tags_job 人群标签任务
|
||
|
||
| 字段名 | 说明 |
|
||
| --------------- | ---------------------------- |
|
||
| id | 自增ID |
|
||
| tag_id | 标签ID |
|
||
| batch_id | 批次ID |
|
||
| tag_type | 标签类型【参与量、消费金额】 |
|
||
| tag_rule | 标签规则【限定参与N次】 |
|
||
| stat_start_time | 统计开始时间 |
|
||
| stat_end_time | 统计结束时间 |
|
||
| status | 计划、重置、完成 |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
|
||
|
||
- 拼团活动表:设定了拼团的成团规则,人群标签的使用可以限定哪些人可见,哪些人可参与。
|
||
- 折扣配置表:拆分出拼团优惠到一个新的表进行多条配置。如果折扣还有更多的复杂规则,则可以配置新的折扣规则表进行处理。
|
||
- 人群标签表:专门来做人群设计记录的,这3张表就是为了把符合规则的人群ID,也就是用户ID,全部跑任务到一个记录下进行使用。比如黑玫瑰人群、高净值人群、拼团履约率90%以上的人群等。
|
||
|
||
|
||
|
||
**(二)参与拼团表**
|
||
|
||
**group_buy_account 拼团账户**
|
||
|
||
| 字段名 | 说明 |
|
||
| --------------------- | ------------ |
|
||
| id | 自增ID |
|
||
| user_id | 用户ID |
|
||
| activity_id | 活动ID |
|
||
| take_limit_count | 拼团次数限制 |
|
||
| take_limit_count_used | 拼团次数消耗 |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
**group_buy_order 用户拼单**
|
||
|
||
一条记录 = 一个拼团**团队**(`team_id` 唯一)
|
||
|
||
| 字段名 | 说明 |
|
||
| --------------- | -------------------------------- |
|
||
| id | 自增ID |
|
||
| team_id | 拼单组队ID |
|
||
| activity_id | 活动ID |
|
||
| source | 渠道 |
|
||
| channel | 来源 |
|
||
| original_price | 原始价格 |
|
||
| deduction_price | 折扣金额 |
|
||
| pay_price | 支付价格 |
|
||
| target_count | 目标数量 |
|
||
| complete_count | 完成数量 |
|
||
| status | 状态(0-拼单中、1-完成、2-失败) |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
**group_buy_order_list 用户拼单明细**
|
||
|
||
一条记录 = **某用户**在该团队里锁的一笔单
|
||
|
||
| 字段名 | 说明 |
|
||
| --------------- | ------------------------------------ |
|
||
| id | 自增ID |
|
||
| user_id | 用户ID |
|
||
| team_id | 拼单组队ID |
|
||
| order_id | 订单ID |
|
||
| activity_id | 活动ID |
|
||
| start_time | 活动开始时间 |
|
||
| end_time | 活动结束时间 |
|
||
| goods_id | 商品ID |
|
||
| source | 渠道 |
|
||
| channel | 来源 |
|
||
| original_price | 原始价格 |
|
||
| deduction_price | 折扣金额 |
|
||
| status | 状态;0 初始锁定、1 消费完成 |
|
||
| out_trade_no | 外部交易单号(确保外部调用唯一幂等) |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
**notify_task 回调任务**
|
||
|
||
| 字段名 | 说明 |
|
||
| -------------- | ---------------------------------- |
|
||
| id | 自增ID |
|
||
| activity_id | 活动ID |
|
||
| order_id | 拼单ID |
|
||
| notify_url | 回调接口 |
|
||
| notify_count | 回调次数(3-5次) |
|
||
| notify_status | 回调状态【初始、完成、重试、失败】 |
|
||
| parameter_json | 参数对象 |
|
||
| create_time | 创建时间 |
|
||
| update_time | 更新时间 |
|
||
|
||
- 拼团账户表:记录用户的拼团参与数据,一个是为了限制用户的参与拼团次数,另外是为了人群标签任务统计数据。
|
||
- 用户拼单表:当有用户发起首次拼单的时候,产生拼单id,并记录所需成团的拼单记录,另外是写上拼团的状态、唯一索引、回调接口等。这样拼团完成就可以回调对接的平台,通知完成了。【微信支付也是这样的设计,回调支付结果,这样的设计可以方便平台化对接】当再有用户参与后,则写入用户拼单明细表。直至达成拼团。
|
||
- 回调任务表:当拼团完成后,要做回调处理。但可能会有失败,所以加入任务的方式进行补偿。如果仍然失败,则需要对接的平台,自己查询拼团结果。
|
||
|
||
|
||
|
||
### 架构设计
|
||
|
||
**MVC架构:**
|
||
|
||

|
||
|
||
**DDD架构:**
|
||
|
||

|
||
|
||
|
||
|
||
## 价格试算
|
||
|
||
```java
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class IndexGroupBuyMarketServiceImpl implements IIndexGroupBuyMarketService {
|
||
|
||
private final DefaultActivityStrategyFactory defaultActivityStrategyFactory;
|
||
|
||
@Override
|
||
public TrialBalanceEntity indexMarketTrial(MarketProductEntity marketProductEntity) throws Exception {
|
||
|
||
StrategyHandler<MarketProductEntity, DefaultActivityStrategyFactory.DynamicContext, TrialBalanceEntity> strategyHandler = defaultActivityStrategyFactory.strategyHandler();
|
||
|
||
TrialBalanceEntity trialBalanceEntity = strategyHandler.apply(marketProductEntity, new DefaultActivityStrategyFactory.DynamicContext());
|
||
|
||
return trialBalanceEntity;
|
||
}
|
||
|
||
}
|
||
```
|
||
|
||
```text
|
||
IndexGroupBuyMarketService
|
||
│
|
||
│ indexMarketTrial()
|
||
▼
|
||
DefaultActivityStrategyFactory
|
||
│ (return rootNode)
|
||
▼
|
||
RootNode.apply()
|
||
│ doApply() (执行)
|
||
│ router() (路由到下一node)
|
||
▼
|
||
SwitchNode.apply()
|
||
│ ...
|
||
▼
|
||
... (可能还有其他节点)
|
||
▼
|
||
EndNode.apply() → 组装结果并返回 TrialBalanceEntity
|
||
▲
|
||
└────────── 最终一路向上 return
|
||
|
||
```
|
||
|
||
`IndexGroupBuyMarketService` 是领域服务,整个价格试算的入口
|
||
|
||
`DefaultActivityStrategyFactory` 帮你拿到 *根节点*,真正的“工厂”工作(多线程预处理、分支路由)都在各 Node 里完成。
|
||
|
||
`DynamicContext` 是一次性创建的共享上下文:谁需要谁就往里放
|
||
|
||
|
||
|
||
## 人群标签数据采集
|
||
|
||
| 步骤 | 目的 | 说明 |
|
||
| ------------------- | ----------------------------------------------- | ------------------------------------------------------------ |
|
||
| **1. 记录日志** | 标明本次批次任务的开始 | 方便后续排查、链路追踪 |
|
||
| **2. 读取批次配置** | 拿到该批次**统计范围、规则、时间窗**等 | 若返回 `null` 通常代表批次号错误或已被清理 |
|
||
| **3. 采集候选用户** | 从业务数仓/模型结果里拉取符合条件的用户 ID 列表 | 真实场景中会:• 调 REST / RPC 拿画像• 或扫离线结果表• 或读 Kafka 流 |
|
||
| **4. 双写标签明细** | 将每个用户与标签的关系永久化 & 提供实时校验能力 | 方法内部两件事:• 插入 `crowd_tags_detail` 表• <br />在 Redis **BitMap** 中把该用户对应位设为 1(幂等处理冲突) |
|
||
| **5. 更新统计量** | 维护标签当前命中人数,用于运营看板 | 这里简单按“新增条数”累加,也可改为重新 `count(*)` 全量回填 |
|
||
| **6. 结束** | 方法返回 void | 如果过程抛异常,调度系统可重试/报警 |
|
||
|
||
> **一句话总结**
|
||
> 这是一个被定时器或消息触发的**离线批量打标签任务**:
|
||
> 拉取任务规则 → (离线)筛出符合条件的用户 → 写库 + 写 Redis 位图 → 更新命中人数。
|
||
> 之后业务系统就能用位图做到毫秒级 `isUserInTag(userId, tagId)` 判断,实现精准运营投放。
|
||
|
||
|
||
|
||
### Bitmap(位图)
|
||
|
||
**概念**
|
||
|
||
- Bitmap 又称 Bitset,是一种用位(bit)来表示状态的数据结构。
|
||
- 它把一个大的“布尔数组”压缩到最小空间:每个元素只占 1 位,要么 0(False)、要么 1(True)。
|
||
|
||
**为什么用 Bitmap?**
|
||
|
||
- **超高空间效率**:1000 万个用户,只需要约 10 MB(1000 万 / 8)。
|
||
- **超快操作**:检查某个索引位是否为 1、计数所有“1”的个数(BITCOUNT)、找出第一个“1”的位置(BITPOS)等,都是 O(1) 或者极快的位运算。
|
||
|
||
**典型场景**
|
||
|
||
- **用户标签 / 权限判断**:把符合某个条件的用户的索引位置设置为 1,以后实时判断“用户 X 是否在标签 A 中?”就只需读一个 bit。
|
||
- **海量去重 / 布隆过滤器**:在超大流量场景下判断“URL 是否已访问过”、“手机号是否已注册”等。
|
||
- **统计分析**:快速统计某个条件下有多少个用户/对象符合(BITCOUNT)。
|
||
|
||
|
||
|
||
## 拼团交易锁单
|
||
|
||

|
||
|
||
下单到支付中间有一个流程,即锁单,比如淘宝京东中,在这个环节(限定时间内)选择使用优惠券、京豆等,可以得到优惠价,再进行支付;拼团场景同理,先加入拼团,进行锁单,然后优惠试算,最后才付款。
|
||
|
||
|
||
|
||
## 拼团结算
|
||
|
||
<img src="https://pic.bitday.top/i/2025/07/15/p876v1-0.png" alt="d56fcfb9708eb0d99600c751808bb843" style="zoom:80%;" />
|
||
|
||
|
||
|
||
## 对接商城和拼团系统
|
||
|
||

|
||
|
||
上面左侧,小型支付商城,用户下单过程。增加一个营销锁单,从营销锁单中获取拼团营销拿到的优惠价格。之后小型商城继续往下走,创建支付订单。右侧,拼团交易平台,提供营销锁单流程,锁单目标、优惠试算,规则过滤,最终落库和返回结果(订单ID、原始价格、折扣金额、支付金额、订单状态)。
|
||
|
||
下面小型支付商城在用户完成支付后,调用拼团**组队结算**,更新当前xx拼团完成人数,当拼团完成后**接收**回调消息执行后续交易结算发货(暂时模拟的)。
|
||
|
||
|
||
|
||
**注意**两个回调函数不要搞混:1:alipay用户支付成功后的回调,告诉pay-mall当前用户支付完毕,可以调用拼团**组队结算**。2:group_buy_market的某teamId拼团达到目标人数,拼团成功的回调,告诉pay-mall可以进行下一步动作,比如给该teamId下的所有人发货。
|
||
|
||
|
||
|
||
## 收获
|
||
|
||
### 实体对象
|
||
|
||
实体是指具有唯一标识的业务对象。
|
||
|
||
在 DDD 分层里,**Domain Entity ≠ 数据库 PO**。
|
||
在 `edu.whut.domain.*.model.entity` 包下放的是**纯粹的业务对象**,它们只表达业务语义(团队 ID、活动时间、优惠金额……),对「数据持久化细节」保持**无感知**。因此它们看起来“字段不全”是正常的:
|
||
|
||
- 它们不会带 `@TableName` / `@TableId` 等 MyBatis-Plus 注解;
|
||
- 也不会出现数据库的技术字段(`id`、`create_time`、`update_time`、`status` 等);
|
||
- 只保留聚合根真正**需要的**业务属性与行为。
|
||
|
||
```java
|
||
@Data
|
||
@Builder
|
||
@AllArgsConstructor
|
||
@NoArgsConstructor
|
||
public class PayActivityEntity {
|
||
|
||
/** 拼单组队ID */
|
||
private String teamId;
|
||
/** 活动ID */
|
||
private Long activityId;
|
||
/** 活动名称 */
|
||
private String activityName;
|
||
/** 拼团开始时间 */
|
||
private Date startTime;
|
||
/** 拼团结束时间 */
|
||
private Date endTime;
|
||
/** 目标数量 */
|
||
private Integer targetCount;
|
||
|
||
}
|
||
```
|
||
|
||
这个也是实体对象,因为多个字段的组合:teamId和activityId能唯一标识这个实体。
|
||
|
||
|
||
|
||
### 模板方法
|
||
|
||
**核心思想**:
|
||
在抽象父类中定义**算法骨架**(固定执行顺序),把某些可变步骤留给子类重写;调用方只用模板方法,保证流程一致。
|
||
|
||
```text
|
||
Client ───▶ AbstractClass
|
||
├─ templateMethod() ←—— 固定流程
|
||
│ step1()
|
||
│ step2() ←—— 抽象,可变
|
||
│ step3()
|
||
└─ hookMethod() ←—— 可选覆盖
|
||
▲
|
||
│ extends
|
||
┌──────────┴──────────┐
|
||
│ ConcreteClassA/B… │
|
||
|
||
```
|
||
|
||
**示例:**
|
||
|
||
```java
|
||
// 1. 抽象模板
|
||
public abstract class AbstractDialog {
|
||
|
||
// 模板方法:固定调用顺序,设为 final 防止子类改流程
|
||
public final void show() {
|
||
initLayout();
|
||
bindEvent();
|
||
beforeDisplay(); // 钩子,可选
|
||
display();
|
||
afterDisplay(); // 钩子,可选
|
||
}
|
||
|
||
// 具体公共步骤
|
||
private void initLayout() {
|
||
System.out.println("加载通用布局文件");
|
||
}
|
||
|
||
// 需要子类实现的抽象步骤
|
||
protected abstract void bindEvent();
|
||
|
||
// 钩子方法,默认空实现
|
||
protected void beforeDisplay() {}
|
||
protected void afterDisplay() {}
|
||
|
||
private void display() {
|
||
System.out.println("弹出对话框");
|
||
}
|
||
}
|
||
|
||
// 2. 子类:登录对话框
|
||
public class LoginDialog extends AbstractDialog {
|
||
@Override
|
||
protected void bindEvent() {
|
||
System.out.println("绑定登录按钮事件");
|
||
}
|
||
@Override
|
||
protected void afterDisplay() {
|
||
System.out.println("focus 到用户名输入框");
|
||
}
|
||
}
|
||
|
||
// 3. 调用
|
||
public class Demo {
|
||
public static void main(String[] args) {
|
||
AbstractDialog dialog = new LoginDialog();
|
||
dialog.show();
|
||
/* 输出:
|
||
加载通用布局文件
|
||
绑定登录按钮事件
|
||
弹出对话框
|
||
focus 到用户名输入框
|
||
*/
|
||
}
|
||
}
|
||
```
|
||
|
||
**要点**
|
||
|
||
- **复用公共流程**:`initLayout()`、`display()` 写一次即可。
|
||
- **限制流程顺序**:`show()` 定为 `final`,防止子类乱改步骤。
|
||
- **钩子方法**:子类可选择性覆盖(如 `beforeDisplay`)。
|
||
|
||
|
||
|
||
### 责任链
|
||
|
||
应用场景:日志系统、审批流程、权限校验——任何需要将请求按阶段传递、并由某一环节决定是否继续或终止处理的地方,都非常适合职责链模式。
|
||
|
||
#### 单例链
|
||
|
||
典型的责任链模式要点:
|
||
|
||
- **解耦请求发送者和处理者**:调用者只持有链头,不关心中间环节。
|
||
- **动态组装**:通过 `appendNext` 可以灵活地增加、删除或重排链上的节点。
|
||
- **可扩展**:新增处理逻辑只需继承 `AbstractLogicLink` 并实现 `apply`,不用改动已有代码。
|
||
|
||
接口定义:`ILogicChainArmory<T, D, R>` 提供添加节点方法和获取节点
|
||
|
||
```java
|
||
//定义了责任链的组装接口:
|
||
public interface ILogicChainArmory<T, D, R> {
|
||
|
||
ILogicLink<T, D, R> next(); //在当前节点中获取下一个节点
|
||
|
||
ILogicLink<T, D, R> appendNext(ILogicLink<T, D, R> next); //把下一个处理节点挂接上来
|
||
|
||
}
|
||
```
|
||
|
||
`ILogicLink<T, D, R>` 继承自 `ILogicChainArmory<T, D, R>`,并额外声明了核心方法 `apply`
|
||
|
||
```java
|
||
public interface ILogicLink<T, D, R> extends ILogicChainArmory<T, D, R> {
|
||
|
||
R apply(T requestParameter, D dynamicContext) throws Exception; //处理请求
|
||
|
||
}
|
||
```
|
||
|
||
抽象基类:`AbstractLogicLink`
|
||
|
||
```java
|
||
public abstract class AbstractLogicLink<T, D, R> implements ILogicLink<T, D, R> {
|
||
|
||
private ILogicLink<T, D, R> next;
|
||
|
||
@Override
|
||
public ILogicLink<T, D, R> next() {
|
||
return next;
|
||
}
|
||
|
||
@Override
|
||
public ILogicLink<T, D, R> appendNext(ILogicLink<T, D, R> next) {
|
||
this.next = next;
|
||
return next;
|
||
}
|
||
|
||
protected R next(T requestParameter, D dynamicContext) throws Exception {
|
||
return next.apply(requestParameter, dynamicContext); //交给下一节点处理
|
||
}
|
||
|
||
}
|
||
```
|
||
|
||
子类只需继承它,重写 `apply(...)`,在合适的条件下要么直接处理并返回,要么调用 `next(requestParameter, dynamicContext)` 继续传递。
|
||
|
||
**使用示例:**
|
||
|
||
```java
|
||
public class AuthLink extends AbstractLogicLink<Request, Context, Response> {
|
||
@Override
|
||
public Response apply(Request req, Context ctx) throws Exception {
|
||
if (!ctx.isAuthenticated()) {
|
||
throw new UnauthorizedException();
|
||
}
|
||
// 认证通过,继续下一个环节
|
||
return next(req, ctx);
|
||
}
|
||
}
|
||
|
||
public class LoggingLink extends AbstractLogicLink<Request, Context, Response> {
|
||
@Override
|
||
public Response apply(Request req, Context ctx) throws Exception {
|
||
System.out.println("Request received: " + req);
|
||
Response resp = next(req, ctx);
|
||
System.out.println("Response sent: " + resp);
|
||
return resp;
|
||
}
|
||
}
|
||
|
||
// 组装责任链 放工厂类factory中实现
|
||
ILogicLink<Request, Context, Response> chain =
|
||
new AuthLink()
|
||
.appendNext(new LoggingLink())
|
||
.appendNext(new BusinessLogicLink());
|
||
|
||
//客户端使用
|
||
Request req = new Request(...);
|
||
Context ctx = new Context(...);
|
||
Response resp = chain.apply(req, ctx);
|
||
```
|
||
|
||
示例图:
|
||
|
||
```text
|
||
AuthLink.apply
|
||
└─▶ LoggingLink.apply
|
||
└─▶ BusinessLogicLink.apply
|
||
└─▶ 返回 Response
|
||
```
|
||
|
||
这种模式链上的每个节点都手动 `next()`到下一节点。
|
||
|
||
|
||
|
||
#### 多例链
|
||
|
||
```java
|
||
/**
|
||
* 通用逻辑处理器接口 —— 责任链中的「节点」要实现的核心契约。
|
||
*/
|
||
public interface ILogicHandler<T, D, R> {
|
||
|
||
/**
|
||
* 默认的 next占位实现,方便节点若不需要向后传递时直接返回 null。
|
||
*/
|
||
default R next(T requestParameter, D dynamicContext) {
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 节点的核心处理方法。
|
||
*/
|
||
R apply(T requestParameter, D dynamicContext) throws Exception;
|
||
|
||
}
|
||
|
||
```
|
||
|
||
```java
|
||
/**
|
||
* 业务链路容器 —— 双向链表实现,同时实现 ILogicHandler,从而可以被当作单个节点使用。
|
||
*/
|
||
public class BusinessLinkedList<T, D, R> extends LinkedList<ILogicHandler<T, D, R>> implements ILogicHandler<T, D, R>{
|
||
|
||
public BusinessLinkedList(String name) {
|
||
super(name);
|
||
}
|
||
|
||
/**
|
||
* BusinessLinkedList是头节点,它的apply方法就是循环调用后面的节点,直至返回。
|
||
* 遍历并执行链路。
|
||
*/
|
||
@Override
|
||
public R apply(T requestParameter, D dynamicContext) throws Exception {
|
||
Node<ILogicHandler<T, D, R>> current = this.first;
|
||
// 顺序执行,直到链尾或返回结果
|
||
while (current != null) {
|
||
ILogicHandler<T, D, R> handler = current.item;
|
||
R result = handler.apply(requestParameter, dynamicContext);
|
||
if (result != null) {
|
||
// 节点命中,立即返回
|
||
return result;
|
||
}
|
||
//result==null,则交给那一节点继续处理
|
||
current = current.next;
|
||
}
|
||
// 全链未命中
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
/**
|
||
* 链路装配工厂 —— 负责把一组 ILogicHandler 顺序注册到 BusinessLinkedList 中。
|
||
*/
|
||
public class LinkArmory<T, D, R> {
|
||
|
||
private final BusinessLinkedList<T, D, R> logicLink;
|
||
|
||
/**
|
||
* @param linkName 链路名称,便于日志排查
|
||
* @param logicHandlers 节点列表,按传入顺序链接
|
||
*/
|
||
@SafeVarargs
|
||
public LinkArmory(String linkName, ILogicHandler<T, D, R>... logicHandlers) {
|
||
logicLink = new BusinessLinkedList<>(linkName);
|
||
for (ILogicHandler<T, D, R> logicHandler: logicHandlers){
|
||
logicLink.add(logicHandler);
|
||
}
|
||
}
|
||
|
||
/** 返回组装完成的链路 */
|
||
public BusinessLinkedList<T, D, R> getLogicLink() {
|
||
return logicLink;
|
||
}
|
||
|
||
}
|
||
|
||
//工厂类,可以定义多条责任链,每条有自己的Bean名称区分。
|
||
@Bean("tradeRuleFilter")
|
||
public BusinessLinkedList<TradeRuleCommandEntity, DynamicContext, TradeRuleFilterBackEntity> tradeRuleFilter(ActivityUsabilityRuleFilter activityUsabilityRuleFilter, UserTakeLimitRuleFilter userTakeLimitRuleFilter) {
|
||
// 1. 组装链
|
||
LinkArmory<TradeRuleCommandEntity, DynamicContext, TradeRuleFilterBackEntity> linkArmory =
|
||
new LinkArmory<>("交易规则过滤链", activityUsabilityRuleFilter, userTakeLimitRuleFilter);
|
||
|
||
// 2. 返回链容器(即可作为责任链使用)
|
||
return linkArmory.getLogicLink();
|
||
}
|
||
```
|
||
|
||
示例图:
|
||
|
||
```text
|
||
BusinessLinkedList.apply ←─ 只有这一层在栈里
|
||
while 循环:
|
||
├─▶ 调用 ActivityUsability.apply → 返回 null → 继续
|
||
├─▶ 调用 UserTakeLimit.apply → 返回 null → 继续
|
||
└─▶ 调用 ... → 返回 Result → break
|
||
|
||
```
|
||
|
||
链头拿着“游标”一个个跑,节点只告诉“命中 / 未命中”。
|
||
|
||
|
||
|
||
### 规则树流程
|
||
|
||

|
||
|
||
**整体分层思路**
|
||
|
||
| 分层 | 作用 | 关键对象 |
|
||
| -------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||
| **通用模板层** | 抽象出与具体业务无关的「规则树」骨架,解决 *如何找到并执行策略* 的共性问题 | `StrategyMapper`、`StrategyHandler`、`AbstractStrategyRouter<T,D,R>` |
|
||
| **业务装配层** | 基于模板,自由拼装出 *一棵* 贴合业务流程的策略树 | `RootNode / SwitchRoot / MarketNode / EndNode …` |
|
||
| **对外暴露层** | 通过 **工厂 + 服务支持类** 将整棵树封装成一个可直接调用的 `StrategyHandler`,并交给 Spring 整体托管 | `DefaultActivityStrategyFactory`、`AbstractGroupBuyMarketSupport` |
|
||
|
||
**通用模板层:规则树的“骨架”**
|
||
|
||
| 角色 | 职责 | 关系 |
|
||
| ------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||
| `StrategyMapper` | **映射器**:依据 `requestParameter + dynamicContext` 选出 *下一个* 策略节点 | 被 `AbstractStrategyRouter` 调用 |
|
||
| `StrategyHandler` | **处理器**:真正执行业务逻辑;`apply` 结束后可返回结果或继续路由 | 节点本身 / 路由器本身都是它的实现 |
|
||
| `AbstractStrategyRouter<T,D,R>` | **路由模板**:① 调用 `get(...)` 找到合适的 `StrategyHandler`;② 调用该 handler 的 `apply(...)`;③ 若未命中则走 `defaultStrategyHandler` | 同时实现 `StrategyMapper` 与 `StrategyHandler`,但自身保持 *抽象*,把细节延迟到子类 |
|
||
|
||
**业务装配层:一棵可编排的策略树**
|
||
|
||
```text
|
||
RootNode -> SwitchRoot -> MarketNode -> EndNode
|
||
↘︎ OtherNode ...
|
||
```
|
||
|
||
- 每个节点
|
||
|
||
继承 `AbstractStrategyRouter`
|
||
|
||
- 实现 `get(...)`:决定当前节点的下一跳是哪一个节点
|
||
- 实现 `apply(...)`:实现节点自身应做的业务动作(或继续下钻)
|
||
|
||
- 组合方式
|
||
|
||
比责任链更灵活:
|
||
|
||
- 一个节点既可以“继续路由”也可以“自己处理完直接返回”
|
||
- 可以随时插拔 / 替换子节点,形成多分支、循环、早停等复杂流转
|
||
|
||
**对外暴露层:工厂 + 服务支持类**
|
||
|
||
| 组件 | 主要职责 |
|
||
| --------------------------------------------- | ------------------------------------------------------------ |
|
||
| `DefaultActivityStrategyFactory` (`@Service`) | **工厂**:1. 在 Spring 启动时注入根节点 `RootNode`;2. 暴露**统一入口** `strategyHandler()` → 返回整个策略树顶点(一个 `StrategyHandler` 实例) |
|
||
| `AbstractGroupBuyMarketSupport` | **业务服务基类**:封装拼团场景下共用的查询、工具方法;供每个**节点**继承使用 |
|
||
|
||
这样,调用方只需
|
||
|
||
```java
|
||
TrialBalanceEntity result =
|
||
factory.strategyHandler().apply(product, new DynamicContext(vo1, vo2));
|
||
```
|
||
|
||
就能驱动整棵策略树,而**完全不用关心**节点搭建、依赖注入等细节。
|
||
|
||
|
||
|
||
### 策略模式
|
||
|
||
**核心思想**:
|
||
把可互换的算法/行为抽成独立策略类,运行时由“上下文”对象选择合适的策略;对调用方来说,只关心统一接口,而非具体实现。
|
||
|
||
```text
|
||
┌───────────────┐
|
||
│ Client │
|
||
└─────▲─────────┘
|
||
│ has-a
|
||
┌─────┴─────────┐ implements
|
||
│ Context │────────────┐ ┌──────────────┐
|
||
│ (使用者) │ strategy └─▶│ Strategy A │
|
||
└───────────────┘ ├──────────────┤
|
||
│ Strategy B │
|
||
└──────────────┘
|
||
|
||
```
|
||
|
||
#### 集合自动注入
|
||
|
||
常见于策略/工厂/插件场景。
|
||
|
||
```java
|
||
@Autowired
|
||
private Map<String, IDiscountCalculateService> discountCalculateServiceMap;
|
||
```
|
||
|
||
**字段类型**:`Map<String, IDiscountCalculateService>`
|
||
|
||
- key—— Bean 的名字
|
||
- 默认是类名首字母小写 (`mjCalculateService`)
|
||
- 或者你在实现类上显式写的 `@Service("MJ")`
|
||
- **value** —— 那个实现类对应的实例
|
||
- **Spring 机制**:
|
||
1. 启动时扫描所有实现 `IDiscountCalculateService` 的 Bean。
|
||
2. 把它们按 “BeanName → Bean 实例” 的映射注入到这张 `Map` 里。
|
||
3. 你一次性就拿到了“策略字典”。
|
||
|
||
**示例:**
|
||
|
||
```java
|
||
@Service("MJ") // ★ 关键:Bean 名即策略键
|
||
public class MJCalculateService extends IDiscountCalculateService {
|
||
|
||
@Override
|
||
protected BigDecimal Calculate(String userId, BigDecimal originalPrice,
|
||
GroupBuyActivityDiscountVO.GroupBuyDiscount groupBuyDiscount) {
|
||
//忽略实现细节
|
||
}
|
||
|
||
@Component
|
||
@RequiredArgsConstructor // 构造器注入更推荐
|
||
public class DiscountContext {
|
||
|
||
private final Map<String, IDiscountCalculateService> discountServiceMap;
|
||
|
||
public BigDecimal calc(String strategyKey,
|
||
String userId,
|
||
BigDecimal originalPrice,
|
||
GroupBuyActivityDiscountVO.GroupBuyDiscount plan) {
|
||
//strategyKey可以是"MJ" ..
|
||
IDiscountCalculateService strategy = discountServiceMap.get(strategyKey);
|
||
if (strategy == null) {
|
||
throw new IllegalArgumentException("无匹配折扣类型: " + strategyKey);
|
||
}
|
||
return strategy.calculate(userId, originalPrice, plan);
|
||
}
|
||
}
|
||
```
|
||
|
||
|
||
|
||
|
||
|
||
### 多线程异步调用
|
||
|
||
如果某任务比较耗时(如加载大量数据),可以考虑开多线程异步调用。
|
||
|
||
```java
|
||
// Runnable ➞ 只能 run(),没有返回值
|
||
public interface Runnable {
|
||
void run();
|
||
}
|
||
|
||
// Callable<V> ➞ call() 能返回 V,也能抛检查型异常
|
||
public interface Callable<V> {
|
||
V call() throws Exception;
|
||
}
|
||
```
|
||
|
||
```java
|
||
public class MyTask implements Callable<String> {
|
||
private final String name;
|
||
public MyTask(String name) {
|
||
this.name = name;
|
||
}
|
||
@Override
|
||
public String call() throws Exception {
|
||
// 模拟耗时操作
|
||
TimeUnit.MILLISECONDS.sleep(300);
|
||
return "任务[" + name + "]的执行结果";
|
||
}
|
||
}
|
||
```
|
||
|
||
```java
|
||
public class SimpleAsyncDemo {
|
||
public static void main(String[] args) {
|
||
// 创建大小为 2 的线程池
|
||
ExecutorService pool = Executors.newFixedThreadPool(2);
|
||
|
||
try {
|
||
// 构造两个任务
|
||
MyTask task1 = new MyTask("A");
|
||
MyTask task2 = new MyTask("B");
|
||
|
||
// 用 FutureTask 包装 Callable
|
||
FutureTask<String> future1 = new FutureTask<>(task1);
|
||
FutureTask<String> future2 = new FutureTask<>(task2);
|
||
|
||
// 提交给线程池异步执行
|
||
pool.execute(future1);
|
||
pool.execute(future2);
|
||
|
||
// 主线程可以先做别的事…
|
||
System.out.println("主线程正在做其他事情…");
|
||
|
||
// 在需要的时候再获取结果(可加超时)
|
||
String result1 = future1.get(1, TimeUnit.SECONDS); //设置超时时间1秒
|
||
String result2 = future2.get(); //无超时时间
|
||
|
||
System.out.println("拿到结果1 → " + result1);
|
||
System.out.println("拿到结果2 → " + result2);
|
||
|
||
} catch (InterruptedException e) {
|
||
Thread.currentThread().interrupt();
|
||
} catch (ExecutionException e) {
|
||
System.err.println("任务执行中出错: " + e.getCause());
|
||
} catch (TimeoutException e) {
|
||
System.err.println("等待结果超时");
|
||
} finally {
|
||
pool.shutdown();
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
|
||
|
||
### 动态配置(热更新)
|
||
|
||
**注解标记**
|
||
用 `@DCCValue("key:default")` 标注需要动态注入的字段,指定对应的 Redis Key(带前缀)及默认值。
|
||
|
||
```java
|
||
// 标记要动态注入的字段
|
||
@Retention(RUNTIME) @Target(FIELD)
|
||
public @interface DCCValue {
|
||
String value(); // "key:default"
|
||
}
|
||
```
|
||
|
||
```java
|
||
// 业务使用示例
|
||
@Service
|
||
public class MyFeature {
|
||
@DCCValue("myFlag:0")
|
||
private String myFlag;
|
||
public boolean enabled() { return "1".equals(myFlag); }
|
||
}
|
||
```
|
||
|
||
**启动时注入**
|
||
实现一个 `BeanPostProcessor`,在每个 Spring Bean 初始化后:
|
||
|
||
- 扫描带 `@DCCValue` 的字段;
|
||
- 拼出完整 Redis Key(如 `dcc_prefix_key`),若不存在则写入默认值,否则读最新值;
|
||
- **反射把值注入到该 Bean 的私有字段**;
|
||
- 将 `(redisKey → Bean 实例)` 记录到内存映射,用于后续热更新。
|
||
|
||
```java
|
||
@Override
|
||
public Object postProcessAfterInitialization(Object bean, String name) {
|
||
Class<?> cls = AopUtils.isAopProxy(bean)
|
||
? AopUtils.getTargetClass(bean)
|
||
: bean.getClass();
|
||
for (Field f : cls.getDeclaredFields()) {
|
||
DCCValue dv = f.getAnnotation(DCCValue.class);
|
||
if (dv==null) continue;
|
||
String[] p = dv.value().split(":");
|
||
String key = PREFIX + p[0], defaultValue = p[1];
|
||
RBucket<String> bucket = redis.getBucket(key);
|
||
String val = bucket.isExists() ? bucket.get() : defaultValue;
|
||
bucket.trySet(defaultValue); //同步redis内容
|
||
injectField(bean, f, val); //反射注入
|
||
beans.put(key, bean);
|
||
}
|
||
return bean;
|
||
}
|
||
```
|
||
|
||
**运行时热更新**
|
||
|
||
- 在同一个组件里,订阅一个 Redis Topic(频道),比如 `"dcc_update"`;
|
||
|
||
- 外部调用发布接口 `PUBLISH dcc_update "key,newValue"`;
|
||
|
||
```java
|
||
//更新配置
|
||
@GetMapping("/dcc/update")
|
||
public void update(@RequestParam String key, @RequestParam String value) {
|
||
dccTopic().publish(key + "," + value);
|
||
}
|
||
```
|
||
|
||
- 订阅者收到后:
|
||
|
||
1. 同步把新值写回 Redis;
|
||
2. 从映射里取出对应 Bean,反射更新它的字段。
|
||
|
||
```java
|
||
// 发布/订阅频道
|
||
@Bean
|
||
public RTopic dccTopic() {
|
||
RTopic t = redis.getTopic("dcc_update");
|
||
t.addListener(String.class, (c,msg)->{
|
||
String[] a = msg.split(",");
|
||
String key = PREFIX + a[0], val = a[1];
|
||
RBucket<String> bucket = redis.getBucket(key);
|
||
if (!bucket.isExists()) return;
|
||
bucket.set(val);
|
||
Object bean = beans.get(key);
|
||
if (bean!=null) injectField(bean, a[0], val);
|
||
});
|
||
return t;
|
||
}
|
||
```
|
||
|
||
|
||
|
||
### OkHttpClient
|
||
|
||
**引入依赖**
|
||
|
||
```xml
|
||
<dependency>
|
||
<groupId>com.squareup.okhttp3</groupId>
|
||
<artifactId>okhttp-sse</artifactId>
|
||
</dependency>
|
||
```
|
||
|
||
**让Spring 管理 Http客户端**
|
||
|
||
- 写配置类
|
||
|
||
```java
|
||
@Configuration
|
||
public class OKHttpClientConfig {
|
||
|
||
@Bean
|
||
public OkHttpClient httpClient() {
|
||
return new OkHttpClient();
|
||
}
|
||
}
|
||
```
|
||
|
||
- 在需要使用的地方注入
|
||
|
||
```java
|
||
@Slf4j
|
||
@Service
|
||
@RequiredArgsConstructor
|
||
public class HttpService {
|
||
|
||
private final OkHttpClient okHttpClient;
|
||
|
||
/**
|
||
* 发送 JSON POST 请求并返回响应内容
|
||
*
|
||
* @param apiUrl 接口地址
|
||
* @param jsonPayload 请求体 JSON 字符串
|
||
*/
|
||
public String postJson(String apiUrl, String jsonPayload) throws IOException {
|
||
//1.构建参数
|
||
MediaType mediaType = MediaType.get("application/json; charset=utf-8");
|
||
RequestBody body = RequestBody.create(jsonPayload, mediaType);
|
||
Request request = new Request.Builder()
|
||
.url(apiUrl)
|
||
.post(body)
|
||
.addHeader("Content-Type", "application/json")
|
||
.build();
|
||
//2.调用接口
|
||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||
if (!response.isSuccessful()) {
|
||
log.error("HTTP 请求失败,URL:{},状态码:{}", apiUrl, response.code());
|
||
throw new IOException("Unexpected HTTP code " + response.code());
|
||
}
|
||
ResponseBody responseBody = response.body();
|
||
return responseBody != null ? responseBody.string() : "";
|
||
} catch (IOException e) {
|
||
log.error("调用 HTTP 接口异常:{}", apiUrl, e);
|
||
throw e;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- 优点:
|
||
|
||
单例复用,性能更优
|
||
|
||
- Spring 默认将 Bean 作为单例管理,整个应用只创建一次 `OkHttpClient`。
|
||
- 内部的连接池、线程池、缓存等资源可以被复用,**避免频繁创建、销毁**带来的开销。
|
||
|
||
统一配置,易于维护
|
||
|
||
- 超时、拦截器、连接池、SSL、日志等配置集中在一个地方,改动一次全局生效。
|
||
- 避免在代码各处手动 `new OkHttpClient()`、重复配置。
|
||
|
||
|
||
|
||
### Retrofit
|
||
|
||
微信登录时,需要调用微信提供的接口做验证。
|
||
|
||
#### 快速入门
|
||
|
||
// 1. 定义 DTO
|
||
|
||
```java
|
||
public class User {
|
||
private String id;
|
||
private String name;
|
||
// … 省略 getters/setters …
|
||
}
|
||
```
|
||
|
||
// 2. 定义 Retrofit 接口
|
||
|
||
```java
|
||
public interface ApiService {
|
||
@GET("users/{id}")
|
||
Call<User> getUser(@Path("id") String id);
|
||
}
|
||
```
|
||
|
||
// 3. 配置 Retrofit 并注册为 Spring Bean
|
||
|
||
```java
|
||
@Configuration
|
||
public class RetrofitConfig {
|
||
|
||
private static final String BASE_URL = "https://api.example.com/";
|
||
|
||
@Bean
|
||
public Retrofit retrofit() {
|
||
return new Retrofit.Builder()
|
||
.baseUrl(BASE_URL) // 公共前缀
|
||
.addConverterFactory(JacksonConverterFactory.create()) // 自动 JSON ↔ DTO
|
||
.build();
|
||
}
|
||
|
||
@Bean
|
||
public ApiService apiService(Retrofit retrofit) {
|
||
// 动态生成 ApiService 实现
|
||
return retrofit.create(ApiService.class);
|
||
}
|
||
}
|
||
```
|
||
|
||
// 4. 在业务层注入并调用
|
||
|
||
```java
|
||
@Service
|
||
public class UserService {
|
||
|
||
private final ApiService apiService;
|
||
|
||
public UserService(ApiService apiService) {
|
||
this.apiService = apiService;
|
||
}
|
||
|
||
/**
|
||
* 同步方式获取用户信息
|
||
*/
|
||
public User getUserById(String userId) {
|
||
try {
|
||
Call<User> call = apiService.getUser(userId);
|
||
Response<User> resp = call.execute();
|
||
if (resp.isSuccessful()) {
|
||
return resp.body();
|
||
} else {
|
||
// 根据业务需要抛出异常或返回 null
|
||
throw new RuntimeException("请求失败,HTTP " + resp.code());
|
||
}
|
||
} catch (Exception e) {
|
||
throw new RuntimeException("调用用户服务出错", e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 异步方式获取用户信息
|
||
*/
|
||
public void getUserAsync(String userId) {
|
||
apiService.getUser(userId).enqueue(new retrofit2.Callback<User>() {
|
||
@Override
|
||
public void onResponse(Call<User> call, Response<User> response) {
|
||
if (response.isSuccessful()) {
|
||
User user = response.body();
|
||
// TODO: 处理 user
|
||
}
|
||
}
|
||
@Override
|
||
public void onFailure(Call<User> call, Throwable t) {
|
||
// TODO: 处理异常
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
Retrofit 在运行时会生成这个接口的实现类,帮你完成:
|
||
|
||
- 拼 URL(把 `{id}` 换成具体值)
|
||
- 发起 GET 请求
|
||
- 拿到响应的 JSON 并自动反序列化成 `User` 对象
|
||
|
||
|
||
|
||
| 核心点 | Apache HttpClient | Retrofit |
|
||
| --------------- | ----------------------------------------- | ------------------------------------------------------------ |
|
||
| 编程模型 | 细粒度调用,手动构造 `HttpGet`/`HttpPost` | 注解驱动接口方法,声明式调用 |
|
||
| 请求定义 | 手动拼接 URL、参数 | 用 `@GET`/`@POST`、`@Path`、`@Query`、`@Body` 注解 |
|
||
| 序列化/反序列化 | 手动调用 `ObjectMapper`/`Gson` | 自动通过 `ConverterFactory`(Jackson/Gson 等) |
|
||
| 同步/异步 | 以同步为主,异步需自行管理线程和回调 | 同一个 `Call<T>` 即可 `execute()`(同步)或 `enqueue()`(异步) |
|
||
| 扩展性与拦截器 | 可配置拦截器,但需手动集成 | 底层基于 OkHttp,天然支持拦截器、连接池、缓存、重试和取消 |
|
||
|
||
|
||
|
||
### 公众号扫码登录流程
|
||
|
||
场景:用微信的能力来替你的网站做“扫码登录”或“社交登录”,代替自己写一整套帐号/密码体系。后台只需要基于 `openid` 做一次性关联(比如把某个微信号和你系统的用户记录挂钩),后续再次扫码就当作同一用户;
|
||
|
||

|
||
|
||
**1.前端请求二维码凭证**
|
||
|
||
- 用户点击“扫码登录”,前端向后端发 `GET /api/v1/login/weixin_qrcode_ticket`。
|
||
|
||
- 后端获取 access_token
|
||
1.先尝试从本地缓存(如 Guava Cache)读取 `access_token`;
|
||
2.若无或已过期,则请求微信接口:
|
||
|
||
```ruby
|
||
GET https://api.weixin.qq.com/cgi-bin/token
|
||
?grant_type=client_credential
|
||
&appid={你的 AppID}
|
||
&secret={你的 AppSecret}
|
||
```
|
||
|
||
微信返回 `{ "access_token":"ACCESS_TOKEN_VALUE", "expires_in":7200 }`,后端缓存这个值(有效期约 2 小时)。
|
||
|
||
- 后端利用 `access_token` 创建二维码 ticket,返给前端。(**每次调用微信会返回不同的ticket**)
|
||
|
||
**2.前端展示二维码**
|
||
|
||
- 前端根据 `ticket` 生成二维码链接:`https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket={ticket}`
|
||
|
||
**3.微信回调后端**
|
||
|
||
- 用户确认**扫描后**,微信服务器向你预先配置的**回调 URL**(如 `POST /api/v1/weixin/portal/receive`)推送包含 `ticket` 和 `openid` 的消息。
|
||
- 后端:将 `ticket → openid` **存入缓存**(`openidToken.put(ticket, openid)`);调用 `sendLoginTemplate(openid)` 给用户推送“登录成功”模板消息(手机公众号上推送,非网页)
|
||
|
||
**4.前端获知登录结果**
|
||
|
||
- **轮询方式**:生成二维码后,前端每隔几秒向后端 `check_login` 接口发送 `ticket`来验证登录状态,后端**查缓存**来判断 `ticket` 对应用户是否成功登录。
|
||
- **推送方式**:前端通过 WebSocket/SSE 建立长连接,后端回调处理完成后直接往该连接推送登录成功及 JWT。
|
||
|
||
|
||
|
||
### 浏览器指纹获取登录ticket
|
||
|
||
在扫码登录流程的基础上改进!!!
|
||
|
||
**目的:**把「这张二维码/ticket」严格绑在发起请求的那台浏览器上,防止别的设备或会话**拿到同一个 ticket** 就能登录。
|
||
|
||
**1.生成指纹**
|
||
前端在用户打开「扫码登录页」时,先用 JS/浏览器 API(比如 User-Agent、屏幕分辨率、插件列表、Canvas 指纹等)算出一个唯一的浏览器指纹 `fp`。
|
||
|
||
**2.获取 ticket 时携带指纹**
|
||
|
||
前端发起请求:
|
||
|
||
```bash
|
||
GET /api/v1/login/weixin_qrcode_ticket_scene?sceneStr=<fp>
|
||
```
|
||
|
||
后端执行:
|
||
|
||
```java
|
||
String ticket = loginPort.createQrCodeTicket(sceneStr);
|
||
sceneTicketCache.put(sceneStr, ticket); // 把 fp→ticket 映射进缓存
|
||
```
|
||
|
||
**3.扫码后轮询校验**
|
||
|
||
前端轮询:传入 `ticket` 和 `sceneStr` 指纹
|
||
|
||
```bash
|
||
GET /api/v1/login/check_login_scene?ticket=<ticket>&sceneStr=<fp>
|
||
```
|
||
|
||
后端逻辑(简化):
|
||
|
||
```java
|
||
// 1) 验证拿到的 sceneStr(fp) 对应的 ticket 是否一致
|
||
String cachedTicket = sceneTicketCache.getIfPresent(sceneStr);
|
||
if (!ticket.equals(cachedTicket)) {
|
||
// fp 不匹配,拒绝
|
||
return NO_LOGIN;
|
||
}
|
||
|
||
// 2) 再看 ticket→openid 有没有被写入(扫码并回调后,saveLoginState 会写入)
|
||
String openid = ticketOpenidCache.getIfPresent(ticket);
|
||
if (openid != null) {
|
||
// 同一浏览器,且已扫码确认,返回 openid(或 JWT)
|
||
return SUCCESS(openid);
|
||
}
|
||
return NO_LOGIN;
|
||
```
|
||
|
||
**4.回调时保存登录状态**
|
||
|
||
当用户扫描二维码,微信会回调你预定的接口地址,拿到 `ticket`、`openid` 后,调用:
|
||
|
||
```java
|
||
ticketOpenidCache.put(ticket, openid); // 保存 ticket→openid
|
||
```
|
||
|
||
注意 `ticketOpenidCache `和 `sceneTicketCache` 一般是一个Cache Bean,这里只是为了更清晰。
|
||
|
||
|
||
|
||
**安全性提升**
|
||
|
||
- **防止“票据劫持”**:别人就算截获了这个 ticket,想拿去自己那台机器上轮询也不行,因为指纹对不上。
|
||
- **防止多人共用**:多个人在不同设备上**同时扫同一个码**,只有最先发起获取 ticket 的那台浏览器能完成登录。
|
||
|
||
|
||
|
||
### 无痕登录
|
||
|
||
“无痕登录”(又称“免扫码登录”或“静默登录”)的核心思想,是在用户首次通过二维码/授权完成登录后,给这台设备发放一份**长期信任凭证**,以后再访问就能悄无声息地登录,不再需要人为地再扫码或输入密码。
|
||
|
||
#### 典型流程
|
||
|
||
**1.初次登录(扫码授权)**
|
||
|
||
即前面**"浏览器指纹获取登录ticket"**的流程
|
||
|
||
**2.后续“无痕”自动登录**
|
||
|
||
1)前端再次打开页面,重新生成指纹
|
||
|
||
2)前端调用“免扫码”接口,仅传递指纹
|
||
|
||
3)后端校验 fingerprint → openid
|
||
|
||
```java
|
||
String openid = sceneLoginCache.getIfPresent(sceneStr);
|
||
if (openid != null) {
|
||
// 直接返回登录态(Session / JWT)
|
||
return SUCCESS(openid);
|
||
} else {
|
||
// 指纹过期或未绑定,返回未登录,前端再走扫码流程
|
||
return NO_LOGIN;
|
||
}
|
||
```
|
||
|
||
4)**成功后**,前端拿到 openid/JWT,直接进入应用,无需用户任何操作。
|