feat: 补充 skills 通用模式缺口,防止常见并发与校验问题
- java-dev: 新增输入校验速查表(数量/金额/分页/百分比的注解约束) - java-dev: 新增 Auth Filter 降级原则(optional-auth 路径降级为匿名) - java-dev: 新增 Redis+DB 多数据源一致性规范(TOCTOU、操作顺序、补偿模式) - frontend-dev: 新增请求体完整性规范(UI 字段必须全部传入 API) - frontend-dev: 新增 uni-app 陷阱参考(页面栈只读、Storage 清理、生命周期双触发、前端校验镜像)
This commit is contained in:
1 parent
5e629ce453
commit
57be77ab75
4 files changed
+297
-1
No files matched your search
@@ -229,6 +229,44 @@ src/assets/styles/
|
||||
|
||||
---
|
||||
|
||||
## 请求体完整性规范
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| ❌ 禁止 UI 可选字段未传入 API | 用户选择/输入的字段必须全部传入请求体 |
|
||||
| ✅ 提交函数与表单字段一一对应 | 用 TypeScript interface 约束请求体 |
|
||||
|
||||
```typescript
|
||||
// ❌ UI 有支付方式选择器,但请求体没传 payMethod
|
||||
const payMethod = ref<'wechat' | 'points' | 'mixed'>('wechat')
|
||||
|
||||
async function createOrder() {
|
||||
await api.createOrder({
|
||||
items: orderItems.value,
|
||||
addressId: selectedAddress.value.id,
|
||||
// payMethod 忘记传了!支付方式选择 UI 形同虚设
|
||||
})
|
||||
}
|
||||
|
||||
// ✅ 请求体与 UI 表单字段对应
|
||||
interface CreateOrderRequest {
|
||||
items: OrderItem[]
|
||||
addressId: number
|
||||
payMethod: 'wechat' | 'points' | 'mixed' // 类型约束确保不遗漏
|
||||
}
|
||||
|
||||
async function createOrder() {
|
||||
const request: CreateOrderRequest = {
|
||||
items: orderItems.value,
|
||||
addressId: selectedAddress.value.id,
|
||||
payMethod: payMethod.value, // TypeScript 会提示缺少字段
|
||||
}
|
||||
await api.createOrder(request)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 错误处理规范
|
||||
|
||||
| 规则 | 说明 |
|
||||
@@ -291,6 +329,7 @@ import type { PageResponse } from '@/types/common'
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `references/frontend-style.md` | UI 风格、Vue 3 规范、Pinia、API 封装、性能优化 |
|
||||
| `references/miniapp-pitfalls.md` | uni-app 陷阱:页面栈只读、Storage 清理时机、生命周期双触发、前端校验镜像 |
|
||||
| `references/date-time.md` | dayjs/date-fns 日期加减、账期计算、禁止月末对齐 |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# uni-app / 小程序常见陷阱
|
||||
|
||||
> 针对 uni-app 开发中容易忽略的平台差异和反模式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 禁止对框架返回的数组调用变更方法
|
||||
|
||||
`getCurrentPages()` 返回的是框架内部页面栈引用,调用 `pop()`/`splice()`/`shift()` 会直接修改页面栈。
|
||||
|
||||
```typescript
|
||||
// ❌ pop() 会删除页面栈最后一个元素
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages.pop() // 变更了页面栈!
|
||||
|
||||
// ✅ 用索引读取,不修改原数组
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
```
|
||||
|
||||
**通用原则**:框架返回的数组/对象视为只读,不调用变更方法。
|
||||
|
||||
---
|
||||
|
||||
## 2. Storage 清理必须在解析成功之后
|
||||
|
||||
`removeStorageSync` 放在 `finally` 或 `try` 块顶部会导致:解析失败时数据已被删除,用户无法重试。
|
||||
|
||||
```typescript
|
||||
// ❌ 解析失败时数据已丢失,用户无法重试
|
||||
const raw = uni.getStorageSync('order_confirm_items')
|
||||
uni.removeStorageSync('order_confirm_items') // 先删再解析
|
||||
const items = JSON.parse(raw) // 如果这里抛异常,数据已丢
|
||||
|
||||
// ❌ finally 中删除同样有问题
|
||||
try {
|
||||
const raw = uni.getStorageSync('order_confirm_items')
|
||||
const items = JSON.parse(raw)
|
||||
} finally {
|
||||
uni.removeStorageSync('order_confirm_items') // 解析失败也删
|
||||
}
|
||||
|
||||
// ✅ 仅在成功解析后删除
|
||||
const raw = uni.getStorageSync('order_confirm_items')
|
||||
let items: OrderItem[]
|
||||
try {
|
||||
items = JSON.parse(raw)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '数据异常,请返回重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 解析成功,安全删除
|
||||
uni.removeStorageSync('order_confirm_items')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. onMounted + onShow 双触发问题
|
||||
|
||||
uni-app 页面首次加载时,`onMounted` 和 `onShow` **都会触发**。数据加载放两处会导致双重请求。
|
||||
|
||||
```typescript
|
||||
// ❌ 首次加载会触发两次 fetchProducts
|
||||
onMounted(() => {
|
||||
fetchProducts(true) // 第 1 次
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
fetchProducts(true) // 第 2 次(首次也触发)
|
||||
})
|
||||
|
||||
// ✅ 数据加载只放 onShow(覆盖首次 + 返回刷新)
|
||||
// onMounted 仅做一次性初始化(如事件监听、DOM 操作)
|
||||
onMounted(() => {
|
||||
// 一次性初始化,如注册事件监听
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
fetchProducts(true) // 首次 + 每次返回都刷新
|
||||
})
|
||||
```
|
||||
|
||||
| 钩子 | 用途 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| `onMounted` | 一次性初始化(事件监听、DOM) | 页面创建时 1 次 |
|
||||
| `onShow` | 数据加载、状态刷新 | 页面创建 + 每次从后台/子页面返回 |
|
||||
| `onLoad` | 接收页面参数 | 页面创建时 1 次(早于 onMounted) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端校验应镜像后端约束
|
||||
|
||||
后端有校验(如 `@Min(1)`、`stock` 上限),前端必须做相同校验提前拦截,避免用户等待网络往返才看到错误。
|
||||
|
||||
```typescript
|
||||
// ❌ 仅检查 stock <= 0,未验证用户选择数量
|
||||
function handleBuy() {
|
||||
if (product.value.stock <= 0) {
|
||||
uni.showToast({ title: '商品已售罄', icon: 'none' })
|
||||
return
|
||||
}
|
||||
createOrder({ quantity: quantity.value }) // quantity 可能 > stock
|
||||
}
|
||||
|
||||
// ✅ 镜像后端校验:数量 >= 1 且 <= 库存
|
||||
function handleBuy() {
|
||||
if (product.value.stock <= 0) {
|
||||
uni.showToast({ title: '商品已售罄', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (quantity.value < 1) {
|
||||
uni.showToast({ title: '数量不能小于 1', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (quantity.value > product.value.stock) {
|
||||
uni.showToast({ title: `库存不足,剩余 ${product.value.stock} 件`, icon: 'none' })
|
||||
return
|
||||
}
|
||||
createOrder({ quantity: quantity.value })
|
||||
}
|
||||
```
|
||||
|
||||
**原则**:前端校验是用户体验优化,后端校验是安全保障,两者都不可省略。
|
||||
|
||||
---
|
||||
|
||||
## 规则溯源
|
||||
|
||||
```
|
||||
> 📋 本回复遵循:`frontend-dev/miniapp-pitfalls.md` - [具体章节]
|
||||
```
|
||||
@@ -311,6 +311,13 @@ public class UserController {
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Filter 降级原则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| ✅ optional-auth 路径遇到无效/过期/不完整 token 时降级为匿名访问 | 不应返回 401/403 |
|
||||
| ❌ 禁止部分凭证用户体验差于匿名用户 | 如:临时 token 在公开接口返回 403 |
|
||||
|
||||
---
|
||||
|
||||
## 输入校验规范
|
||||
@@ -319,8 +326,20 @@ public class UserController {
|
||||
|------|------|
|
||||
| ❌ 禁止 `@RequestBody` 不加 `@Valid` | 所有请求体必须校验 |
|
||||
| ✅ DTO 字段加约束注解 | `@NotBlank`、`@Size`、`@Pattern` 等 |
|
||||
| ✅ 数值字段加范围约束 | `@Min`、`@Max`、`@Positive` 等 |
|
||||
| ✅ 分页参数加上限 | `size` 必须 `@Max(100)` 防止大量查询 |
|
||||
| ✅ 枚举/状态字段白名单校验 | 自定义校验器或 `@Pattern` |
|
||||
|
||||
**常见 DTO 字段校验速查**:
|
||||
|
||||
| 字段类型 | 必须注解 | 说明 |
|
||||
|---------|---------|------|
|
||||
| 数量 quantity | `@NotNull @Min(1)` | 防止 0 或负数(负数可导致反向操作) |
|
||||
| 金额 amount/price | `@NotNull @Positive` | 或 `@DecimalMin("0.01")` |
|
||||
| 分页 size | `@Min(1) @Max(100)` | 防止 `size=999999` 拖垮数据库 |
|
||||
| 分页 page | `@Min(1)` | 页码从 1 开始 |
|
||||
| 百分比 rate | `@Min(0) @Max(100)` | 视业务定义范围 |
|
||||
|
||||
```java
|
||||
// ❌ 无校验,任意输入直接进入业务逻辑
|
||||
@PostMapping("/ship")
|
||||
@@ -335,6 +354,25 @@ public record ShippingRequest(
|
||||
@NotBlank @Size(max = 500) String shippingInfo,
|
||||
@Pattern(regexp = "pending|shipped|delivered") String giftStatus
|
||||
) {}
|
||||
|
||||
// ❌ quantity 只有 @NotNull,负数会导致 Redis DECRBY 反向加库存
|
||||
public record CreateOrderRequest(
|
||||
@NotNull Integer quantity // 可提交 0 或负数
|
||||
) {}
|
||||
|
||||
// ✅ 数量必须 >= 1
|
||||
public record CreateOrderRequest(
|
||||
@NotNull @Min(1) Integer quantity
|
||||
) {}
|
||||
|
||||
// ❌ 分页无上限,用户可传 size=999999
|
||||
@GetMapping("/orders")
|
||||
public Result list(@RequestParam int page, @RequestParam int size) { ... }
|
||||
|
||||
// ✅ 分页参数加约束
|
||||
@GetMapping("/orders")
|
||||
public Result list(@RequestParam @Min(1) int page,
|
||||
@RequestParam @Min(1) @Max(100) int size) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -371,7 +409,7 @@ log.debug("Finding user by id: " + userId);
|
||||
| `references/java-style.md` | 命名约定、异常处理、Spring Boot、测试规范 |
|
||||
| `references/collections.md` | 不可变集合(Guava)、字符串分割 |
|
||||
| `references/concurrency.md` | 线程池配置、CompletableFuture 超时 |
|
||||
| `references/concurrency-db-patterns.md` | Get-Or-Create 并发、N+1 防范、原子更新 |
|
||||
| `references/concurrency-db-patterns.md` | Get-Or-Create 并发、N+1 防范、原子更新、Redis+DB 一致性 |
|
||||
| `references/code-patterns.md` | 卫语句、枚举优化、策略工厂模式 |
|
||||
| `references/date-time.md` | 日期加减、账期计算、禁止月末对齐 |
|
||||
|
||||
|
||||
@@ -112,3 +112,91 @@ public void pay(Long accountId, BigDecimal amount) {
|
||||
") FROM Order o WHERE o.tenantId = :tenantId")
|
||||
StatsDTO getStats(@Param("tenantId") Long tenantId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Redis + DB 多数据源一致性
|
||||
|
||||
**场景**:库存、余额等热数据用 Redis 做原子扣减,DB 做持久化记录。
|
||||
**核心问题**:Redis 操作不参与 `@Transactional` 回滚,两者之间存在一致性风险。
|
||||
|
||||
### 5.1 单一数据源原则(禁止 TOCTOU 跨数据源)
|
||||
|
||||
校验和扣减必须在同一数据源完成,禁止用 DB 校验 + Redis 扣减。
|
||||
|
||||
```java
|
||||
// ❌ TOCTOU 竞态:DB 校验与 Redis 扣减数据源不一致
|
||||
@Transactional
|
||||
public void createOrder(OrderRequest request) {
|
||||
Product product = productRepo.findById(request.getProductId());
|
||||
if (product.getStock() < request.getQuantity()) { // DB 校验
|
||||
throw new BusinessException("库存不足");
|
||||
}
|
||||
// 高并发下 DB stock 可能大于 Redis stock,校验通过但扣减失败
|
||||
redisTemplate.opsForValue().decrement("stock:" + request.getProductId(), request.getQuantity()); // Redis 扣减
|
||||
}
|
||||
|
||||
// ✅ 完全依赖 Redis 原子扣减结果,不做 DB 前置校验
|
||||
@Transactional
|
||||
public void createOrder(OrderRequest request) {
|
||||
Long remaining = redisTemplate.opsForValue()
|
||||
.decrement("stock:" + request.getProductId(), request.getQuantity());
|
||||
if (remaining == null || remaining < 0) { // null: key 不存在; < 0: 库存不足
|
||||
// 扣减失败,回补 Redis
|
||||
redisTemplate.opsForValue()
|
||||
.increment("stock:" + request.getProductId(), request.getQuantity());
|
||||
throw new BusinessException("库存不足");
|
||||
}
|
||||
// Redis 扣减成功,继续 DB 操作...
|
||||
orderRepo.save(buildOrder(request));
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 操作顺序:先 DB 后 Redis,或 Redis 先扣 + 失败补偿
|
||||
|
||||
Redis 操作不受 `@Transactional` 管理,DB 回滚时 Redis 不会自动恢复。
|
||||
|
||||
| 方案 | 操作顺序 | 适用场景 |
|
||||
|------|---------|---------|
|
||||
| **A. 先 DB 后 Redis** | DB 写入 -> Redis 更新 | DB 为主、Redis 为缓存 |
|
||||
| **B. Redis 先扣 + 补偿** | Redis 扣减 -> DB 写入 -> 失败时回补 Redis | Redis 为主、高并发扣减 |
|
||||
|
||||
```java
|
||||
// ❌ Redis 先扣,DB 后写,无补偿 —— DB 回滚后 Redis 库存永久丢失
|
||||
@Transactional
|
||||
public void lockStock(Long productId, int quantity) {
|
||||
redisTemplate.opsForValue().decrement("stock:" + productId, quantity); // Redis 先扣
|
||||
stockLockRepo.save(new StockLock(productId, quantity)); // DB 后写
|
||||
// 如果 save 抛异常,DB 回滚,但 Redis 已扣减,库存"漏"掉
|
||||
}
|
||||
|
||||
// ✅ 方案 B:Redis 先扣 + try-catch 补偿
|
||||
@Transactional
|
||||
public void lockStock(Long productId, int quantity) {
|
||||
// 1. Redis 原子扣减
|
||||
Long remaining = redisTemplate.opsForValue()
|
||||
.decrement("stock:" + productId, quantity);
|
||||
if (remaining == null || remaining < 0) {
|
||||
redisTemplate.opsForValue().increment("stock:" + productId, quantity);
|
||||
throw new BusinessException("库存不足");
|
||||
}
|
||||
try {
|
||||
// 2. DB 持久化
|
||||
stockLockRepo.save(new StockLock(productId, quantity));
|
||||
orderRepo.save(buildOrder(productId, quantity));
|
||||
} catch (Exception e) {
|
||||
// 3. DB 失败,回补 Redis
|
||||
redisTemplate.opsForValue().increment("stock:" + productId, quantity);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 决策速查
|
||||
|
||||
```
|
||||
Redis + DB 一起用?
|
||||
├─ Redis 仅做缓存(读多写少)→ 方案 A:先写 DB,再更新 Redis
|
||||
├─ Redis 做原子扣减(高并发)→ 方案 B:Redis 先扣,失败补偿
|
||||
└─ 校验逻辑 → 只信任原子操作结果,不做跨数据源前置校验
|
||||
```
|
||||
Reference in new issue
Block a user