b34adbd011
- 业务逻辑前置方案 - 本地数据缓存方案(IndexedDB + LocalStorage) - 前端加密计算方案(Web Crypto API) - 实时计算客户端化方案 - 离线功能实现 - 对现有改进项的影响分析
1407 lines
40 KiB
Markdown
1407 lines
40 KiB
Markdown
# IMPL-005: 客户端优先架构调整方案
|
||
|
||
> 文档编号: GYM-IMPL-005
|
||
> 版本: v1.0
|
||
> 日期: 2026-04-05
|
||
> 作者: 张翔
|
||
> 状态: 正式发布
|
||
|
||
---
|
||
|
||
## 文档修订历史
|
||
|
||
| 版本 | 日期 | 作者 | 修订内容 |
|
||
|------|------|------|---------|
|
||
| v1.0 | 2026-04-05 | 张翔 | 创建客户端优先架构调整方案 |
|
||
|
||
---
|
||
|
||
## 一、需求分析
|
||
|
||
### 1.1 问题背景
|
||
|
||
当前架构存在以下问题:
|
||
- 后端资源占用高,服务器压力大
|
||
- 用户体验受网络延迟影响
|
||
- 缺少离线功能支持
|
||
- 客户端算力未充分利用
|
||
|
||
### 1.2 调整目标
|
||
|
||
**核心目标**:让资源和算力留在客户端,减少后端的资源占用和压力
|
||
|
||
**具体目标**:
|
||
1. **业务逻辑前置**:将数据验证、格式化、计算等逻辑移到前端
|
||
2. **本地数据缓存**:使用LocalStorage、IndexedDB等本地存储,减少服务器请求
|
||
3. **前端加密计算**:在前端进行数据加密、解密,减少服务器计算压力
|
||
4. **实时计算客户端化**:将实时计算、状态管理、复杂业务逻辑移到客户端
|
||
|
||
### 1.3 成功标准
|
||
|
||
- 后端资源占用降低50%
|
||
- 用户响应速度提升60%
|
||
- 支持离线功能
|
||
- 缓存命中率≥90%
|
||
- 用户体验满意度≥95%
|
||
|
||
---
|
||
|
||
## 二、架构对比
|
||
|
||
### 2.1 传统架构
|
||
|
||
```
|
||
┌─────────────┐
|
||
│ 客户端 │
|
||
│ (展示层) │
|
||
└──────┬──────┘
|
||
│ HTTP请求
|
||
↓
|
||
┌─────────────────────────────────┐
|
||
│ 后端服务器 │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ 业务逻辑层 │ │
|
||
│ │ - 数据验证 │ │
|
||
│ │ - 数据格式化 │ │
|
||
│ │ - 数据计算 │ │
|
||
│ │ - 状态管理 │ │
|
||
│ │ - 加密解密 │ │
|
||
│ └─────────────────────────┘ │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ 缓存层 │ │
|
||
│ │ - Redis缓存 │ │
|
||
│ └─────────────────────────┘ │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ 数据访问层 │ │
|
||
│ │ - 数据库操作 │ │
|
||
│ └─────────────────────────┘ │
|
||
└─────────────────────────────────┘
|
||
│
|
||
↓
|
||
┌─────────────┐
|
||
│ 数据库 │
|
||
└─────────────┘
|
||
```
|
||
|
||
**问题**:
|
||
- ❌ 后端承担所有业务逻辑,压力大
|
||
- ❌ 每次请求都需要访问服务器,响应慢
|
||
- ❌ 无离线功能
|
||
- ❌ 客户端算力浪费
|
||
|
||
---
|
||
|
||
### 2.2 客户端优先架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ 客户端 │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ 业务逻辑层 │ │
|
||
│ │ - 数据验证 │ │
|
||
│ │ - 数据格式化 │ │
|
||
│ │ - 实时计算 │ │
|
||
│ │ - 状态管理 │ │
|
||
│ │ - 加密解密 │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ 本地缓存层 │ │
|
||
│ │ - LocalStorage │ │
|
||
│ │ - IndexedDB │ │
|
||
│ │ - 内存缓存 │ │
|
||
│ └─────────────────────────────────┘ │
|
||
│ ┌─────────────────────────────────┐ │
|
||
│ │ 离线队列 │ │
|
||
│ │ - 离线操作队列 │ │
|
||
│ │ - 后台同步 │ │
|
||
│ └─────────────────────────────────┘ │
|
||
└─────────────────────────────────────────┘
|
||
│ HTTP请求(仅核心数据)
|
||
↓
|
||
┌─────────────────────────────────┐
|
||
│ 后端服务器 │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ 核心业务层 │ │
|
||
│ │ - 权限控制 │ │
|
||
│ │ - 数据一致性验证 │ │
|
||
│ │ - 事务管理 │ │
|
||
│ └─────────────────────────┘ │
|
||
│ ┌─────────────────────────┐ │
|
||
│ │ 数据访问层 │ │
|
||
│ │ - 数据库操作 │ │
|
||
│ └─────────────────────────┘ │
|
||
└─────────────────────────────────┘
|
||
│
|
||
↓
|
||
┌─────────────┐
|
||
│ 数据库 │
|
||
└─────────────┘
|
||
```
|
||
|
||
**优势**:
|
||
- ✅ 后端压力大幅降低
|
||
- ✅ 用户响应速度提升
|
||
- ✅ 支持离线功能
|
||
- ✅ 充分利用客户端算力
|
||
|
||
---
|
||
|
||
## 三、业务逻辑前置方案
|
||
|
||
### 3.1 职责划分
|
||
|
||
#### 前端承担
|
||
|
||
| 业务逻辑 | 说明 | 实现方式 |
|
||
|---------|------|---------|
|
||
| 数据验证 | 表单验证、业务规则验证 | Validator库 |
|
||
| 数据格式化 | 日期格式、金额格式、电话号码格式 | Formatter工具 |
|
||
| 数据计算 | 金额计算、库存计算、名额计算 | 计算函数 |
|
||
| 状态管理 | 订单状态、支付状态、预约状态 | Vuex/Pinia |
|
||
| 数据聚合 | 列表数据聚合、统计数据聚合 | 前端聚合 |
|
||
|
||
#### 后端保留
|
||
|
||
| 业务逻辑 | 说明 | 实现方式 |
|
||
|---------|------|---------|
|
||
| 核心业务逻辑 | 支付流程、权限控制 | Service层 |
|
||
| 数据持久化 | 数据库操作 | Repository层 |
|
||
| 安全验证 | 身份认证、权限验证 | Security层 |
|
||
| 数据一致性 | 事务管理、最终一致性验证 | Transaction |
|
||
|
||
---
|
||
|
||
### 3.2 数据验证前置
|
||
|
||
#### 前端验证规则
|
||
|
||
```javascript
|
||
// 验证规则定义
|
||
const validationRules = {
|
||
phone: {
|
||
required: true,
|
||
pattern: /^1[3-9]\d{9}$/,
|
||
message: '请输入有效的手机号码'
|
||
},
|
||
idCard: {
|
||
required: true,
|
||
pattern: /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/,
|
||
message: '请输入有效的身份证号'
|
||
},
|
||
amount: {
|
||
required: true,
|
||
min: 0.01,
|
||
max: 999999.99,
|
||
message: '金额必须在0.01-999999.99之间'
|
||
}
|
||
};
|
||
|
||
// 验证函数
|
||
function validate(data, rules) {
|
||
const errors = {};
|
||
|
||
for (const [field, rule] of Object.entries(rules)) {
|
||
const value = data[field];
|
||
|
||
// 必填验证
|
||
if (rule.required && !value) {
|
||
errors[field] = rule.message || `${field}不能为空`;
|
||
continue;
|
||
}
|
||
|
||
// 正则验证
|
||
if (rule.pattern && !rule.pattern.test(value)) {
|
||
errors[field] = rule.message || `${field}格式不正确`;
|
||
}
|
||
|
||
// 范围验证
|
||
if (rule.min !== undefined && value < rule.min) {
|
||
errors[field] = rule.message || `${field}不能小于${rule.min}`;
|
||
}
|
||
if (rule.max !== undefined && value > rule.max) {
|
||
errors[field] = rule.message || `${field}不能大于${rule.max}`;
|
||
}
|
||
}
|
||
|
||
return {
|
||
valid: Object.keys(errors).length === 0,
|
||
errors
|
||
};
|
||
}
|
||
```
|
||
|
||
#### 后端验证简化
|
||
|
||
```java
|
||
// 后端只做核心验证
|
||
@PostMapping("/api/members")
|
||
public Member createMember(@RequestBody MemberRequest request) {
|
||
// 1. 身份认证
|
||
authenticationService.authenticate();
|
||
|
||
// 2. 权限验证
|
||
authorizationService.checkPermission("member:create");
|
||
|
||
// 3. 数据一致性验证
|
||
if (memberRepository.existsByPhone(request.getPhone())) {
|
||
throw new BusinessException("手机号已存在");
|
||
}
|
||
|
||
// 4. 数据持久化
|
||
return memberService.createMember(request);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.3 数据计算前置
|
||
|
||
#### 金额计算
|
||
|
||
```javascript
|
||
// 前端金额计算
|
||
class PriceCalculator {
|
||
// 计算课程总价
|
||
calculateTotalPrice(courses, discount) {
|
||
const subtotal = courses.reduce((sum, course) => {
|
||
return sum + course.price * course.quantity;
|
||
}, 0);
|
||
|
||
const discountAmount = subtotal * discount;
|
||
const total = subtotal - discountAmount;
|
||
|
||
return {
|
||
subtotal: this.formatPrice(subtotal),
|
||
discount: this.formatPrice(discountAmount),
|
||
total: this.formatPrice(total)
|
||
};
|
||
}
|
||
|
||
// 格式化价格
|
||
formatPrice(price) {
|
||
return {
|
||
value: price,
|
||
display: `¥${price.toFixed(2)}`
|
||
};
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const calculator = new PriceCalculator();
|
||
const result = calculator.calculateTotalPrice(
|
||
[
|
||
{ price: 100, quantity: 2 },
|
||
{ price: 200, quantity: 1 }
|
||
],
|
||
0.1 // 10%折扣
|
||
);
|
||
|
||
console.log(result);
|
||
// {
|
||
// subtotal: { value: 400, display: '¥400.00' },
|
||
// discount: { value: 40, display: '¥40.00' },
|
||
// total: { value: 360, display: '¥360.00' }
|
||
// }
|
||
```
|
||
|
||
#### 库存计算
|
||
|
||
```javascript
|
||
// 前端库存计算
|
||
class InventoryCalculator {
|
||
constructor() {
|
||
this.inventory = new Map();
|
||
}
|
||
|
||
// 更新库存
|
||
updateInventory(productId, quantity) {
|
||
const current = this.inventory.get(productId) || 0;
|
||
this.inventory.set(productId, current + quantity);
|
||
}
|
||
|
||
// 检查库存
|
||
checkInventory(productId, requiredQuantity) {
|
||
const available = this.inventory.get(productId) || 0;
|
||
return available >= requiredQuantity;
|
||
}
|
||
|
||
// 预留库存
|
||
reserveInventory(productId, quantity) {
|
||
if (!this.checkInventory(productId, quantity)) {
|
||
throw new Error('库存不足');
|
||
}
|
||
|
||
const current = this.inventory.get(productId);
|
||
this.inventory.set(productId, current - quantity);
|
||
|
||
return {
|
||
productId,
|
||
reserved: quantity,
|
||
remaining: this.inventory.get(productId)
|
||
};
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、本地数据缓存方案
|
||
|
||
### 4.1 缓存策略
|
||
|
||
#### 缓存层次
|
||
|
||
```
|
||
┌─────────────────────────────────┐
|
||
│ 内存缓存(最快) │
|
||
│ - 热点数据 │
|
||
│ - 会话数据 │
|
||
└─────────────────────────────────┘
|
||
↓ 未命中
|
||
┌─────────────────────────────────┐
|
||
│ IndexedDB(中等) │
|
||
│ - 结构化数据 │
|
||
│ - 大量数据 │
|
||
└─────────────────────────────────┘
|
||
↓ 未命中
|
||
┌─────────────────────────────────┐
|
||
│ LocalStorage(较慢) │
|
||
│ - 配置数据 │
|
||
│ - 用户设置 │
|
||
└─────────────────────────────────┘
|
||
↓ 未命中
|
||
┌─────────────────────────────────┐
|
||
│ 服务器(最慢) │
|
||
│ - 核心数据 │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
#### 缓存策略表
|
||
|
||
| 数据类型 | 缓存方式 | TTL | 同步策略 | 离线支持 |
|
||
|---------|---------|-----|---------|---------|
|
||
| 课程信息 | IndexedDB | 1小时 | 后台同步 | ✅ |
|
||
| 会员信息 | LocalStorage | 30分钟 | 登录时同步 | ✅ |
|
||
| 教练信息 | IndexedDB | 1小时 | 后台同步 | ✅ |
|
||
| 预约名额 | 内存缓存 | 5分钟 | 实时同步 | ❌ |
|
||
| 用户设置 | LocalStorage | 永久 | 手动同步 | ✅ |
|
||
| 订单列表 | IndexedDB | 10分钟 | 后台同步 | ✅ |
|
||
| 支付记录 | IndexedDB | 1天 | 后台同步 | ✅ |
|
||
|
||
---
|
||
|
||
### 4.2 IndexedDB缓存实现
|
||
|
||
#### 数据库初始化
|
||
|
||
```javascript
|
||
// IndexedDB初始化
|
||
class CacheDB {
|
||
constructor() {
|
||
this.db = null;
|
||
}
|
||
|
||
async init() {
|
||
return new Promise((resolve, reject) => {
|
||
const request = indexedDB.open('GymManageDB', 1);
|
||
|
||
request.onerror = () => reject(request.error);
|
||
request.onsuccess = () => {
|
||
this.db = request.result;
|
||
resolve(this.db);
|
||
};
|
||
|
||
request.onupgradeneeded = (event) => {
|
||
const db = event.target.result;
|
||
|
||
// 创建对象存储
|
||
if (!db.objectStoreNames.contains('courses')) {
|
||
const courseStore = db.createObjectStore('courses', { keyPath: 'id' });
|
||
courseStore.createIndex('categoryId', 'categoryId', { unique: false });
|
||
courseStore.createIndex('coachId', 'coachId', { unique: false });
|
||
}
|
||
|
||
if (!db.objectStoreNames.contains('members')) {
|
||
db.createObjectStore('members', { keyPath: 'id' });
|
||
}
|
||
|
||
if (!db.objectStoreNames.contains('coaches')) {
|
||
db.createObjectStore('coaches', { keyPath: 'id' });
|
||
}
|
||
|
||
if (!db.objectStoreNames.contains('reservations')) {
|
||
const reservationStore = db.createObjectStore('reservations', { keyPath: 'id' });
|
||
reservationStore.createIndex('memberId', 'memberId', { unique: false });
|
||
reservationStore.createIndex('courseId', 'courseId', { unique: false });
|
||
}
|
||
};
|
||
});
|
||
}
|
||
|
||
// 获取数据
|
||
async get(storeName, key) {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = this.db.transaction([storeName], 'readonly');
|
||
const store = transaction.objectStore(storeName);
|
||
const request = store.get(key);
|
||
|
||
request.onerror = () => reject(request.error);
|
||
request.onsuccess = () => resolve(request.result);
|
||
});
|
||
}
|
||
|
||
// 保存数据
|
||
async put(storeName, data) {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = this.db.transaction([storeName], 'readwrite');
|
||
const store = transaction.objectStore(storeName);
|
||
|
||
// 添加缓存时间戳
|
||
const dataWithTimestamp = {
|
||
...data,
|
||
cachedAt: Date.now()
|
||
};
|
||
|
||
const request = store.put(dataWithTimestamp);
|
||
|
||
request.onerror = () => reject(request.error);
|
||
request.onsuccess = () => resolve(request.result);
|
||
});
|
||
}
|
||
|
||
// 删除数据
|
||
async delete(storeName, key) {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = this.db.transaction([storeName], 'readwrite');
|
||
const store = transaction.objectStore(storeName);
|
||
const request = store.delete(key);
|
||
|
||
request.onerror = () => reject(request.error);
|
||
request.onsuccess = () => resolve(request.result);
|
||
});
|
||
}
|
||
|
||
// 清空存储
|
||
async clear(storeName) {
|
||
return new Promise((resolve, reject) => {
|
||
const transaction = this.db.transaction([storeName], 'readwrite');
|
||
const store = transaction.objectStore(storeName);
|
||
const request = store.clear();
|
||
|
||
request.onerror = () => reject(request.error);
|
||
request.onsuccess = () => resolve(request.result);
|
||
});
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const cacheDB = new CacheDB();
|
||
await cacheDB.init();
|
||
```
|
||
|
||
#### 缓存服务封装
|
||
|
||
```javascript
|
||
// 缓存服务
|
||
class CacheService {
|
||
constructor() {
|
||
this.db = null;
|
||
this.memoryCache = new Map();
|
||
this.TTL = {
|
||
courses: 3600000, // 1小时
|
||
members: 1800000, // 30分钟
|
||
coaches: 3600000, // 1小时
|
||
reservations: 300000, // 5分钟
|
||
settings: Infinity // 永久
|
||
};
|
||
}
|
||
|
||
async init() {
|
||
this.db = new CacheDB();
|
||
await this.db.init();
|
||
}
|
||
|
||
// 获取数据(带缓存)
|
||
async get(storeName, key, fetcher) {
|
||
// 1. 检查内存缓存
|
||
const memoryKey = `${storeName}:${key}`;
|
||
const memoryCached = this.memoryCache.get(memoryKey);
|
||
|
||
if (memoryCached && !this.isExpired(memoryCached.cachedAt, storeName)) {
|
||
return memoryCached;
|
||
}
|
||
|
||
// 2. 检查IndexedDB缓存
|
||
const dbCached = await this.db.get(storeName, key);
|
||
|
||
if (dbCached && !this.isExpired(dbCached.cachedAt, storeName)) {
|
||
// 更新内存缓存
|
||
this.memoryCache.set(memoryKey, dbCached);
|
||
return dbCached;
|
||
}
|
||
|
||
// 3. 从服务器获取
|
||
if (fetcher) {
|
||
const data = await fetcher();
|
||
|
||
// 保存到缓存
|
||
await this.put(storeName, key, data);
|
||
|
||
return data;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// 保存数据
|
||
async put(storeName, key, data) {
|
||
const dataWithTimestamp = {
|
||
...data,
|
||
cachedAt: Date.now()
|
||
};
|
||
|
||
// 保存到IndexedDB
|
||
await this.db.put(storeName, dataWithTimestamp);
|
||
|
||
// 更新内存缓存
|
||
const memoryKey = `${storeName}:${key}`;
|
||
this.memoryCache.set(memoryKey, dataWithTimestamp);
|
||
}
|
||
|
||
// 检查是否过期
|
||
isExpired(cachedAt, storeName) {
|
||
const ttl = this.TTL[storeName];
|
||
if (ttl === Infinity) return false;
|
||
|
||
return Date.now() - cachedAt > ttl;
|
||
}
|
||
|
||
// 清空缓存
|
||
async clear(storeName) {
|
||
await this.db.clear(storeName);
|
||
|
||
// 清空内存缓存
|
||
for (const key of this.memoryCache.keys()) {
|
||
if (key.startsWith(`${storeName}:`)) {
|
||
this.memoryCache.delete(key);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const cacheService = new CacheService();
|
||
await cacheService.init();
|
||
|
||
// 获取课程信息(优先缓存)
|
||
const course = await cacheService.get('courses', 'course-001', async () => {
|
||
const response = await fetch('/api/courses/course-001');
|
||
return response.json();
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### 4.3 离线功能实现
|
||
|
||
#### 离线队列
|
||
|
||
```javascript
|
||
// 离线队列管理
|
||
class OfflineQueue {
|
||
constructor() {
|
||
this.queue = [];
|
||
this.isOnline = navigator.onLine;
|
||
|
||
// 监听网络状态
|
||
window.addEventListener('online', () => this.onOnline());
|
||
window.addEventListener('offline', () => this.onOffline());
|
||
|
||
// 从LocalStorage恢复队列
|
||
this.loadQueue();
|
||
}
|
||
|
||
// 添加离线操作
|
||
async add(operation) {
|
||
const queueItem = {
|
||
id: this.generateId(),
|
||
operation: operation.type,
|
||
data: operation.data,
|
||
createdAt: Date.now(),
|
||
status: 'PENDING',
|
||
retryCount: 0
|
||
};
|
||
|
||
this.queue.push(queueItem);
|
||
await this.saveQueue();
|
||
|
||
// 显示提示
|
||
this.showOfflineNotification(queueItem);
|
||
|
||
return queueItem;
|
||
}
|
||
|
||
// 网络恢复时同步
|
||
async onOnline() {
|
||
this.isOnline = true;
|
||
|
||
// 同步所有待处理操作
|
||
for (const item of this.queue) {
|
||
if (item.status === 'PENDING') {
|
||
await this.syncOperation(item);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 网络断开时处理
|
||
onOffline() {
|
||
this.isOnline = false;
|
||
}
|
||
|
||
// 同步单个操作
|
||
async syncOperation(item) {
|
||
try {
|
||
const response = await fetch(item.operation.url, {
|
||
method: item.operation.method,
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(item.data)
|
||
});
|
||
|
||
if (response.ok) {
|
||
item.status = 'SYNCED';
|
||
await this.saveQueue();
|
||
|
||
// 显示成功通知
|
||
this.showSyncSuccessNotification(item);
|
||
} else {
|
||
throw new Error('同步失败');
|
||
}
|
||
} catch (error) {
|
||
item.retryCount++;
|
||
|
||
if (item.retryCount >= 3) {
|
||
item.status = 'FAILED';
|
||
this.showSyncFailedNotification(item);
|
||
}
|
||
|
||
await this.saveQueue();
|
||
}
|
||
}
|
||
|
||
// 生成唯一ID
|
||
generateId() {
|
||
return `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||
}
|
||
|
||
// 保存队列到LocalStorage
|
||
async saveQueue() {
|
||
localStorage.setItem('offlineQueue', JSON.stringify(this.queue));
|
||
}
|
||
|
||
// 从LocalStorage加载队列
|
||
loadQueue() {
|
||
const saved = localStorage.getItem('offlineQueue');
|
||
if (saved) {
|
||
this.queue = JSON.parse(saved);
|
||
}
|
||
}
|
||
|
||
// 显示离线通知
|
||
showOfflineNotification(item) {
|
||
// 使用Notification API或自定义UI
|
||
console.log(`操作已保存到离线队列: ${item.operation}`);
|
||
}
|
||
|
||
// 显示同步成功通知
|
||
showSyncSuccessNotification(item) {
|
||
console.log(`操作已同步: ${item.operation}`);
|
||
}
|
||
|
||
// 显示同步失败通知
|
||
showSyncFailedNotification(item) {
|
||
console.error(`操作同步失败: ${item.operation}`);
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const offlineQueue = new OfflineQueue();
|
||
|
||
// 离线预约
|
||
async function createReservation(reservation) {
|
||
if (!navigator.onLine) {
|
||
// 离线时添加到队列
|
||
await offlineQueue.add({
|
||
type: {
|
||
url: '/api/reservations',
|
||
method: 'POST'
|
||
},
|
||
data: reservation
|
||
});
|
||
|
||
return {
|
||
status: 'OFFLINE_SAVED',
|
||
message: '预约已保存,将在网络恢复后同步'
|
||
};
|
||
} else {
|
||
// 在线时直接请求
|
||
const response = await fetch('/api/reservations', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(reservation)
|
||
});
|
||
|
||
return response.json();
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 五、前端加密计算方案
|
||
|
||
### 5.1 加密方案对比
|
||
|
||
| 加密类型 | 传统方案(后端加密) | 新方案(前端加密) | 优势 |
|
||
|---------|-------------------|-------------------|------|
|
||
| 敏感数据加密 | 后端AES-256加密 | 前端Web Crypto API | 减少服务器计算压力 |
|
||
| 密码加密 | 后端BCrypt | 前端BCrypt + 后端验证 | 数据传输更安全 |
|
||
| 支付数据加密 | 后端加密 | 前端端到端加密 | 端到端安全 |
|
||
| 数据签名 | 后端签名 | 前端HMAC签名 | 减少服务器压力 |
|
||
|
||
---
|
||
|
||
### 5.2 Web Crypto API实现
|
||
|
||
#### AES-256-GCM加密
|
||
|
||
```javascript
|
||
// 前端加密工具
|
||
class EncryptionUtil {
|
||
constructor() {
|
||
this.key = null;
|
||
}
|
||
|
||
// 初始化密钥
|
||
async init(password) {
|
||
// 从密码派生密钥
|
||
const encoder = new TextEncoder();
|
||
const keyMaterial = await crypto.subtle.importKey(
|
||
'raw',
|
||
encoder.encode(password),
|
||
'PBKDF2',
|
||
false,
|
||
['deriveKey']
|
||
);
|
||
|
||
this.key = await crypto.subtle.deriveKey(
|
||
{
|
||
name: 'PBKDF2',
|
||
salt: encoder.encode('gym-manage-salt'),
|
||
iterations: 100000,
|
||
hash: 'SHA-256'
|
||
},
|
||
keyMaterial,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
false,
|
||
['encrypt', 'decrypt']
|
||
);
|
||
}
|
||
|
||
// 加密
|
||
async encrypt(plaintext) {
|
||
const encoder = new TextEncoder();
|
||
const data = encoder.encode(plaintext);
|
||
|
||
// 生成IV
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
|
||
// 加密
|
||
const encrypted = await crypto.subtle.encrypt(
|
||
{ name: 'AES-GCM', iv: iv },
|
||
this.key,
|
||
data
|
||
);
|
||
|
||
// 组合IV和密文
|
||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||
combined.set(iv);
|
||
combined.set(new Uint8Array(encrypted), iv.length);
|
||
|
||
// Base64编码
|
||
return btoa(String.fromCharCode(...combined));
|
||
}
|
||
|
||
// 解密
|
||
async decrypt(ciphertext) {
|
||
// Base64解码
|
||
const combined = new Uint8Array(
|
||
atob(ciphertext).split('').map(c => c.charCodeAt(0))
|
||
);
|
||
|
||
// 分离IV和密文
|
||
const iv = combined.slice(0, 12);
|
||
const encrypted = combined.slice(12);
|
||
|
||
// 解密
|
||
const decrypted = await crypto.subtle.decrypt(
|
||
{ name: 'AES-GCM', iv: iv },
|
||
this.key,
|
||
encrypted
|
||
);
|
||
|
||
// 解码
|
||
const decoder = new TextDecoder();
|
||
return decoder.decode(decrypted);
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const encryptionUtil = new EncryptionUtil();
|
||
await encryptionUtil.init('user-password');
|
||
|
||
// 加密敏感数据
|
||
const encrypted = await encryptionUtil.encrypt('13800138000');
|
||
console.log('加密后:', encrypted);
|
||
|
||
// 解密
|
||
const decrypted = await encryptionUtil.decrypt(encrypted);
|
||
console.log('解密后:', decrypted);
|
||
```
|
||
|
||
#### HMAC签名
|
||
|
||
```javascript
|
||
// 数据签名
|
||
class SignatureUtil {
|
||
constructor() {
|
||
this.key = null;
|
||
}
|
||
|
||
// 初始化签名密钥
|
||
async init(secret) {
|
||
const encoder = new TextEncoder();
|
||
this.key = await crypto.subtle.importKey(
|
||
'raw',
|
||
encoder.encode(secret),
|
||
{ name: 'HMAC', hash: 'SHA-256' },
|
||
false,
|
||
['sign', 'verify']
|
||
);
|
||
}
|
||
|
||
// 签名
|
||
async sign(data) {
|
||
const encoder = new TextEncoder();
|
||
const signature = await crypto.subtle.sign(
|
||
'HMAC',
|
||
this.key,
|
||
encoder.encode(JSON.stringify(data))
|
||
);
|
||
|
||
return btoa(String.fromCharCode(...new Uint8Array(signature)));
|
||
}
|
||
|
||
// 验证签名
|
||
async verify(data, signature) {
|
||
const encoder = new TextEncoder();
|
||
const signatureBytes = new Uint8Array(
|
||
atob(signature).split('').map(c => c.charCodeAt(0))
|
||
);
|
||
|
||
return await crypto.subtle.verify(
|
||
'HMAC',
|
||
this.key,
|
||
signatureBytes,
|
||
encoder.encode(JSON.stringify(data))
|
||
);
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const signatureUtil = new SignatureUtil();
|
||
await signatureUtil.init('api-secret-key');
|
||
|
||
// 签名请求数据
|
||
const requestData = { orderId: 'ORDER-001', amount: 100 };
|
||
const signature = await signatureUtil.sign(requestData);
|
||
|
||
// 发送请求时带上签名
|
||
fetch('/api/payments', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Signature': signature
|
||
},
|
||
body: JSON.stringify(requestData)
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
### 5.3 后端验证简化
|
||
|
||
```java
|
||
// 后端验证加密数据
|
||
@RestController
|
||
public class PaymentController {
|
||
|
||
@PostMapping("/api/payments")
|
||
public PaymentResponse createPayment(
|
||
@RequestBody PaymentRequest request,
|
||
@RequestHeader("X-Signature") String signature
|
||
) {
|
||
// 1. 验证签名
|
||
if (!signatureService.verify(request, signature)) {
|
||
throw new SecurityException("签名验证失败");
|
||
}
|
||
|
||
// 2. 验证加密格式
|
||
if (!encryptionService.validateFormat(request.getEncryptedData())) {
|
||
throw new ValidationException("加密数据格式不正确");
|
||
}
|
||
|
||
// 3. 核心业务逻辑
|
||
return paymentService.createPayment(request);
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、实时计算客户端化方案
|
||
|
||
### 6.1 实时计算场景
|
||
|
||
| 计算场景 | 前端承担 | 后端承担 | 同步策略 |
|
||
|---------|---------|---------|---------|
|
||
| 预约名额计算 | ✅ 实时计算 | ✅ 最终一致性验证 | 实时同步 |
|
||
| 金额计算 | ✅ 实时计算 | ✅ 订单创建时验证 | 即时验证 |
|
||
| 库存计算 | ✅ 实时计算 | ✅ 最终一致性验证 | 实时同步 |
|
||
| 统计报表 | ✅ 前端聚合 | ✅ 数据源 | 后台同步 |
|
||
|
||
---
|
||
|
||
### 6.2 预约名额实时计算
|
||
|
||
```javascript
|
||
// 预约名额管理
|
||
class QuotaManager {
|
||
constructor() {
|
||
this.quotas = new Map(); // courseId -> quota
|
||
this.reservations = new Map(); // courseId -> reservations
|
||
}
|
||
|
||
// 初始化名额
|
||
initQuota(courseId, totalQuota) {
|
||
this.quotas.set(courseId, {
|
||
total: totalQuota,
|
||
used: 0,
|
||
available: totalQuota
|
||
});
|
||
}
|
||
|
||
// 更新预约
|
||
updateReservation(courseId, reservation) {
|
||
if (!this.reservations.has(courseId)) {
|
||
this.reservations.set(courseId, []);
|
||
}
|
||
|
||
const reservations = this.reservations.get(courseId);
|
||
const existingIndex = reservations.findIndex(r => r.id === reservation.id);
|
||
|
||
if (existingIndex >= 0) {
|
||
// 更新现有预约
|
||
reservations[existingIndex] = reservation;
|
||
} else {
|
||
// 添加新预约
|
||
reservations.push(reservation);
|
||
}
|
||
|
||
// 重新计算名额
|
||
this.recalculateQuota(courseId);
|
||
}
|
||
|
||
// 重新计算名额
|
||
recalculateQuota(courseId) {
|
||
const quota = this.quotas.get(courseId);
|
||
const reservations = this.reservations.get(courseId) || [];
|
||
|
||
// 计算已用名额
|
||
quota.used = reservations.filter(r =>
|
||
r.status === 'CONFIRMED' || r.status === 'PENDING'
|
||
).length;
|
||
|
||
// 计算可用名额
|
||
quota.available = quota.total - quota.used;
|
||
|
||
// 触发UI更新
|
||
this.emitQuotaUpdate(courseId, quota);
|
||
}
|
||
|
||
// 检查名额
|
||
checkQuota(courseId, requiredQuantity = 1) {
|
||
const quota = this.quotas.get(courseId);
|
||
return quota && quota.available >= requiredQuantity;
|
||
}
|
||
|
||
// 预留名额
|
||
reserveQuota(courseId, quantity = 1) {
|
||
if (!this.checkQuota(courseId, quantity)) {
|
||
throw new Error('名额不足');
|
||
}
|
||
|
||
const quota = this.quotas.get(courseId);
|
||
quota.used += quantity;
|
||
quota.available -= quantity;
|
||
|
||
this.emitQuotaUpdate(courseId, quota);
|
||
|
||
return {
|
||
courseId,
|
||
reserved: quantity,
|
||
remaining: quota.available
|
||
};
|
||
}
|
||
|
||
// 释放名额
|
||
releaseQuota(courseId, quantity = 1) {
|
||
const quota = this.quotas.get(courseId);
|
||
quota.used -= quantity;
|
||
quota.available += quantity;
|
||
|
||
this.emitQuotaUpdate(courseId, quota);
|
||
}
|
||
|
||
// 触发UI更新
|
||
emitQuotaUpdate(courseId, quota) {
|
||
// 使用Vue的响应式系统或自定义事件
|
||
window.dispatchEvent(new CustomEvent('quotaUpdate', {
|
||
detail: { courseId, quota }
|
||
}));
|
||
}
|
||
}
|
||
|
||
// 使用示例
|
||
const quotaManager = new QuotaManager();
|
||
|
||
// 初始化课程名额
|
||
quotaManager.initQuota('course-001', 20);
|
||
|
||
// 检查名额
|
||
if (quotaManager.checkQuota('course-001')) {
|
||
// 预留名额
|
||
quotaManager.reserveQuota('course-001');
|
||
|
||
// 创建预约
|
||
await createReservation({ courseId: 'course-001', memberId: 'member-001' });
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 6.3 状态管理
|
||
|
||
```javascript
|
||
// 使用Pinia进行状态管理
|
||
import { defineStore } from 'pinia';
|
||
|
||
export const useReservationStore = defineStore('reservation', {
|
||
state: () => ({
|
||
reservations: [],
|
||
quotas: new Map(),
|
||
loading: false,
|
||
error: null
|
||
}),
|
||
|
||
getters: {
|
||
// 获取课程预约
|
||
getReservationsByCourse: (state) => (courseId) => {
|
||
return state.reservations.filter(r => r.courseId === courseId);
|
||
},
|
||
|
||
// 获取可用名额
|
||
getAvailableQuota: (state) => (courseId) => {
|
||
const quota = state.quotas.get(courseId);
|
||
return quota ? quota.available : 0;
|
||
}
|
||
},
|
||
|
||
actions: {
|
||
// 创建预约
|
||
async createReservation(reservation) {
|
||
this.loading = true;
|
||
|
||
try {
|
||
// 检查名额
|
||
if (!this.checkQuota(reservation.courseId)) {
|
||
throw new Error('名额不足');
|
||
}
|
||
|
||
// 预留名额
|
||
this.reserveQuota(reservation.courseId);
|
||
|
||
// 发送请求
|
||
const response = await fetch('/api/reservations', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(reservation)
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
// 添加到本地状态
|
||
this.reservations.push(data);
|
||
|
||
return data;
|
||
} catch (error) {
|
||
// 释放名额
|
||
this.releaseQuota(reservation.courseId);
|
||
|
||
this.error = error.message;
|
||
throw error;
|
||
} finally {
|
||
this.loading = false;
|
||
}
|
||
},
|
||
|
||
// 检查名额
|
||
checkQuota(courseId) {
|
||
const quota = this.quotas.get(courseId);
|
||
return quota && quota.available > 0;
|
||
},
|
||
|
||
// 预留名额
|
||
reserveQuota(courseId) {
|
||
const quota = this.quotas.get(courseId);
|
||
if (quota) {
|
||
quota.used++;
|
||
quota.available--;
|
||
}
|
||
},
|
||
|
||
// 释放名额
|
||
releaseQuota(courseId) {
|
||
const quota = this.quotas.get(courseId);
|
||
if (quota) {
|
||
quota.used--;
|
||
quota.available++;
|
||
}
|
||
}
|
||
}
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 七、对现有改进项的影响
|
||
|
||
### 7.1 IMPL-002:敏感数据加密存储方案
|
||
|
||
**调整建议**:前端加密 + 后端验证
|
||
|
||
**原方案**:
|
||
- 后端加密存储
|
||
- 后端解密读取
|
||
|
||
**新方案**:
|
||
- 前端加密(Web Crypto API)
|
||
- 后端验证加密格式和完整性
|
||
- 后端二次加密存储(可选)
|
||
|
||
**代码调整**:
|
||
|
||
```javascript
|
||
// 前端加密
|
||
async function encryptSensitiveData(data) {
|
||
const encryptionUtil = new EncryptionUtil();
|
||
await encryptionUtil.init('user-key');
|
||
|
||
return {
|
||
phone: await encryptionUtil.encrypt(data.phone),
|
||
idCard: await encryptionUtil.encrypt(data.idCard),
|
||
bankCard: await encryptionUtil.encrypt(data.bankCard)
|
||
};
|
||
}
|
||
|
||
// 发送加密数据
|
||
const encryptedData = await encryptSensitiveData(memberData);
|
||
await fetch('/api/members', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(encryptedData)
|
||
});
|
||
```
|
||
|
||
**优势**:
|
||
- ✅ 减少服务器计算压力
|
||
- ✅ 数据传输更安全
|
||
- ✅ 提升用户体验
|
||
|
||
---
|
||
|
||
### 7.2 IMPL-003:预约高峰期性能优化方案
|
||
|
||
**调整建议**:客户端缓存 + 本地计算 + 后台同步
|
||
|
||
**原方案**:
|
||
- Redis缓存
|
||
- 数据库读写分离
|
||
- 消息队列削峰
|
||
|
||
**新方案**:
|
||
- IndexedDB本地缓存
|
||
- 前端实时计算
|
||
- 后台同步 + 最终一致性验证
|
||
|
||
**架构调整**:
|
||
|
||
```
|
||
原架构:
|
||
客户端 → Redis缓存 → 数据库主从 → 消息队列
|
||
|
||
新架构:
|
||
客户端(IndexedDB + 实时计算) → 后端(最终一致性验证) → 数据库
|
||
```
|
||
|
||
**优势**:
|
||
- ✅ 缓存命中率提升至90%+
|
||
- ✅ 用户响应速度提升60%
|
||
- ✅ 支持离线功能
|
||
- ✅ 后端压力降低70%
|
||
|
||
---
|
||
|
||
### 7.3 IMPL-004:支付接口幂等性校验方案
|
||
|
||
**调整建议**:前端幂等键管理 + 后端简化验证
|
||
|
||
**原方案**:
|
||
- 后端分布式锁
|
||
- 后端幂等性检查
|
||
|
||
**新方案**:
|
||
- 前端幂等键生成和管理
|
||
- 前端本地幂等性检查
|
||
- 后端数据库唯一索引验证
|
||
|
||
**代码调整**:
|
||
|
||
```javascript
|
||
// 前端幂等性管理
|
||
class PaymentIdempotent {
|
||
constructor() {
|
||
this.pendingPayments = new Map();
|
||
}
|
||
|
||
generateRequestNo() {
|
||
return `PAY-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||
}
|
||
|
||
checkLocal(requestNo) {
|
||
return this.pendingPayments.has(requestNo);
|
||
}
|
||
|
||
markPending(requestNo, payment) {
|
||
this.pendingPayments.set(requestNo, payment);
|
||
localStorage.setItem('pendingPayments',
|
||
JSON.stringify(Array.from(this.pendingPayments.entries())));
|
||
}
|
||
|
||
markComplete(requestNo) {
|
||
this.pendingPayments.delete(requestNo);
|
||
localStorage.setItem('pendingPayments',
|
||
JSON.stringify(Array.from(this.pendingPayments.entries())));
|
||
}
|
||
}
|
||
```
|
||
|
||
**优势**:
|
||
- ✅ 减少Redis依赖
|
||
- ✅ 前端即时响应
|
||
- ✅ 简化后端逻辑
|
||
|
||
---
|
||
|
||
## 八、实施步骤
|
||
|
||
| 阶段 | 任务 | 负责人 | 完成时间 | 验收标准 |
|
||
|------|------|--------|---------|---------|
|
||
| **阶段1:基础设施** | | | | |
|
||
| 1 | IndexedDB数据库设计 | 前端开发 | 2天 | 数据库设计文档完成 |
|
||
| 2 | 缓存服务实现 | 前端开发 | 3天 | 缓存服务单元测试通过 |
|
||
| 3 | 离线队列实现 | 前端开发 | 2天 | 离线队列测试通过 |
|
||
| **阶段2:业务逻辑前置** | | | | |
|
||
| 4 | 数据验证前置 | 前端开发 | 2天 | 验证规则测试通过 |
|
||
| 5 | 数据计算前置 | 前端开发 | 3天 | 计算逻辑测试通过 |
|
||
| 6 | 状态管理实现 | 前端开发 | 2天 | 状态管理测试通过 |
|
||
| **阶段3:加密计算前置** | | | | |
|
||
| 7 | Web Crypto API集成 | 前端开发 | 2天 | 加密功能测试通过 |
|
||
| 8 | 签名机制实现 | 前端开发 | 1天 | 签名验证测试通过 |
|
||
| 9 | 后端验证简化 | 后端开发 | 2天 | 后端测试通过 |
|
||
| **阶段4:实时计算客户端化** | | | | |
|
||
| 10 | 预约名额实时计算 | 前端开发 | 2天 | 实时计算测试通过 |
|
||
| 11 | 库存实时计算 | 前端开发 | 2天 | 实时计算测试通过 |
|
||
| 12 | 后台同步机制 | 前端开发 | 2天 | 同步机制测试通过 |
|
||
| **阶段5:集成测试** | | | | |
|
||
| 13 | 端到端测试 | 测试工程师 | 3天 | 所有测试通过 |
|
||
| 14 | 性能测试 | 测试工程师 | 2天 | 性能指标达标 |
|
||
| 15 | 灰度发布 | 运维工程师 | 1天 | 灰度发布成功 |
|
||
|
||
---
|
||
|
||
## 九、验收标准
|
||
|
||
### 9.1 功能验收
|
||
|
||
- [ ] 业务逻辑前置覆盖率≥80%
|
||
- [ ] 本地缓存命中率≥90%
|
||
- [ ] 前端加密功能正常
|
||
- [ ] 实时计算准确性100%
|
||
- [ ] 离线功能正常
|
||
|
||
### 9.2 性能验收
|
||
|
||
- [ ] 后端资源占用降低50%
|
||
- [ ] 用户响应速度提升60%
|
||
- [ ] 缓存命中率≥90%
|
||
- [ ] 离线功能可用
|
||
|
||
### 9.3 用户体验验收
|
||
|
||
- [ ] 用户满意度≥95%
|
||
- [ ] 页面加载速度≤2秒
|
||
- [ ] 操作响应时间≤500ms
|
||
- [ ] 离线功能体验良好
|
||
|
||
---
|
||
|
||
## 十、风险与应对
|
||
|
||
### 10.1 风险识别
|
||
|
||
**风险1:客户端计算错误**
|
||
- 应对:后端最终一致性验证 + 数据校验
|
||
|
||
**风险2:缓存数据不一致**
|
||
- 应对:定期同步 + 版本控制 + 冲突解决机制
|
||
|
||
**风险3:离线数据丢失**
|
||
- 应对:LocalStorage持久化 + IndexedDB备份
|
||
|
||
**风险4:前端加密密钥管理**
|
||
- 应对:密钥派生 + 安全存储 + 定期轮换
|
||
|
||
**风险5:浏览器兼容性**
|
||
- 应对:Polyfill + 降级方案 + 浏览器检测
|
||
|
||
---
|
||
|
||
## 十一、相关文档
|
||
|
||
- [改进路线图](../05-PLANS/改进路线图.md)
|
||
- [IMPL-002-敏感数据加密存储方案](./IMPL-002-敏感数据加密存储方案.md)
|
||
- [IMPL-003-预约高峰期性能优化方案](./IMPL-003-预约高峰期性能优化方案.md)
|
||
- [IMPL-004-支付接口幂等性校验方案](./IMPL-004-支付接口幂等性校验方案.md)
|