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:
wwj committed 2026-03-05 15:40:56 +08:00
1 parent 5e629ce453
commit 57be77ab75
4 files changed
+297 -1

No files matched your search

+39
View File
@@ -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` - [具体章节]
```
+39 -1
View File
@@ -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 先扣,失败补偿
└─ 校验逻辑 → 只信任原子操作结果,不做跨数据源前置校验
```