一次从同步到异步的华丽转身 ✨
📋 目录
🎬 背景问题
问题场景 😱
长耗时接口(30-60秒)遇到网关超时(30秒)的问题:
- 接口调用需要 30-60秒 才能返回
- 网关设置了 30秒超时重试
- 结果:接口超时 → 网关重试 → 重复调用 → 用户体验差 😭
解决方案 💡
异步处理 + Redis缓存 = 完美解决!
- 用户发起请求 → 立即返回"处理中" → 后台异步处理
- 处理完成后 → Redis存储结果 → 发送通知
- 用户点击通知链接 → 自动打开页面 → 显示处理结果
🎯 核心改动
后端改动
1️⃣ 新增 Redis Key
javascript
// constant/redis.js
OP_TASK_AI: 'task_ai' // 用于存储异步任务状态
2️⃣ 新增查询路由
javascript
// router/xxx.js
router.get(
'/api/task/result_by_key',
controller.task.getTaskResult,
)
3️⃣ Controller层:两个接口的职责分离
| 接口 | 用途 | 参数 | 返回 |
|---|---|---|---|
create_task |
🚀 创建任务 | input, type, related_id |
处理结果 or {status: 'processing'} |
get_task_result |
🔍 查询结果 | taskKey |
Redis中的完整数据 or null |
关键代码:
javascript
// 创建任务接口(主动)
async createTask(ctx) {
const { input, type, related_id } = ctx.query
const result = await this.ctx.service.task.createTask(
input, type, related_id
)
// ...
}
// 查询结果接口(被动)
async getTaskResult(ctx) {
const { taskKey } = ctx.query
const result = await this.ctx.service.task.getTaskResult(taskKey)
// ...
}
4️⃣ Service层:核心方法
createTask - 创建异步任务
javascript
async createTask(input, type, related_id) {
// 1. 检查Redis是否有结果
// 2. 如果有 → 直接返回
// 3. 如果没有 → 创建任务,异步处理
// 4. 返回 { status: 'processing' }
}
processTaskAsync - 异步处理任务
javascript
async processTaskAsync(input, type, redisKey, related_id) {
// 1. 调用长耗时接口
// 2. 处理成功 → 更新Redis状态为 'completed'
// 3. 发送通知(包含跳转链接)
}
getTaskResult - 查询任务结果
javascript
async getTaskResult(taskKey) {
// 直接从Redis查询,不创建任务
const redisKey = `${REDIS_KEYS.OP_TASK_AI}:${taskKey}`
const redisValue = await this.ctx.getRedisValue(redisKey)
return redisValue ? JSON.parse(redisValue) : null
}
Redis数据结构:
javascript
// processing 状态
{
status: 'processing',
type: 'task_type',
input: 'input_data',
related_id: 123,
createdAt: 1234567890
}
// completed 状态
{
status: 'completed',
result: '处理结果',
type: 'task_type',
input: 'input_data',
related_id: 123,
completedAt: 1234567890
}
前端改动
1️⃣ Service层:新增查询接口
typescript
// service/xxx/index.ts
async function getTaskResult(params: { taskKey: string }) {
const { data } = await httpGet('/api/task/result_by_key', params)
return data
}
2️⃣ 列表页:URL参数处理
typescript
// 解析URL参数
const { relatedId } = querystring.parse(window.location.search)
// 通过URL打开详情
const openDetailByUrl = async () => {
if (relatedId) {
const detail = data.list.find((item) => `${item.id}` === relatedId)
if (detail) {
dispatchUpdate(detail)
}
}
}
// 监听数据加载完成,自动打开
useEffect(() => {
if (data.list.length > 0) {
openDetailByUrl()
}
}, [relatedId, data])
关闭时清除URL参数:
typescript
const handleClose = () => {
dispatchClose()
onReset() // 重置搜索参数,URL会自动更新
}
3️⃣ 详情页:从URL查询结果
typescript
// 解析URL参数
const { taskKey } = querystring.parse(window.location.search) as { taskKey: string }
// 查询Redis并显示结果
const loadTaskResult = async () => {
if (taskKey) {
const taskResult = await getTaskResult({ taskKey })
if (taskResult) {
const { status, result, type } = taskResult
// 设置类型
if (type) {
setTaskType(type)
}
// 根据状态显示结果
if (status === 'completed' && result) {
setShowResult(true)
setResultValue(result)
} else if (status === 'processing') {
message.info('处理中,请稍候...')
} else if (status === 'failed') {
message.warning('处理失败,请重试')
}
}
}
}
// 页面打开时自动查询
useEffect(() => {
if (taskKey && visible) {
loadTaskResult()
}
}, [taskKey, visible])
重要:Form和State的区别
typescript
// ❌ 错误:组件不在Form.Item中,不能用form管理
form.setFieldsValue({ field: value }) // 没用!
// ✅ 正确:直接用state管理
const [field, setField] = useState<string>()
<Select
value={field} // 绑定state
onChange={setField} // 更新state
>
🛠️ 技术实现
Redis Key设计
规则 :${REDIS_KEYS.OP_TASK_AI}:${taskKey}
为什么只用单个唯一标识?
- ✅ 简单:不需要组合多个参数
- ✅ 唯一:taskKey本身就是唯一的
- ✅ 易查:前端只需要提取唯一标识
示例:
input: https://example.com/file/1765423642667_169.jpeg
taskKey: 1765423642667_169.jpeg
redisKey: task_ai:1765423642667_169.jpeg
异步处理流程
用户点击识别
↓
检查Redis是否有结果
↓
有结果 → 直接返回
↓
无结果 → 设置Redis状态为 'processing'
↓
异步调用 processRecognitionTask
↓
调用AI识别接口(30-60秒)
↓
识别成功 → 更新Redis状态为 'completed'
↓
发送通知(包含跳转链接)
通知链接格式
https://example.com/page/detail?relatedId=${related_id}&taskKey=${taskKey}
为什么这样设计?
relatedId:用于获取关联数据详情,打开页面taskKey:用于查询Redis任务结果
URL参数处理
前端解析:
typescript
import querystring from 'query-string'
// 解析URL参数
const { relatedId, taskKey } = querystring.parse(window.location.search)
// ⚠️ 注意:querystring.parse 返回的类型可能是 string | string[] | null
// 需要类型断言或类型守卫
const { taskKey } = querystring.parse(window.location.search) as { taskKey: string }
清除URL参数:
typescript
// 方法1:重置defaultParams(推荐)
onReset() // 会设置所有参数为默认值,空值会被过滤
// 方法2:手动清除特定参数
const clearUrlExtraParams = () => {
const currentParams = querystring.parse(location.search)
const { relatedId, taskKey, ...restParams } = currentParams
// 只保留 restParams
}
🐛 踩坑记录
坑1:Select组件不在Form.Item中
问题:
typescript
// Select不在Form.Item中
<Select onChange={onVendorTypeChange}>
{/* ... */}
</Select>
// 但是代码中用了form.setFieldsValue
form.setFieldsValue({ vendorType: value }) // ❌ 没用!
原因:Form只能管理Form.Item中的字段
解决:
typescript
// ✅ 直接用state管理
const [vendorType, setVendorType] = useState<string>()
const onVendorTypeChange = (value: string) => {
setVendorType(value) // 只更新state
}
<Select value={vendorType} onChange={onVendorTypeChange}>
坑2:URL参数被useSyncParams重置
问题:
- URL中有额外参数(如
relatedId,taskKey) - 但是
useSyncParamshook 会重写URL,只保留defaultParams中的参数 - 结果:额外参数被清除了 😱
原因:
typescript
// useSyncParams 的 onParamSync 会重写URL
const onParamSync = () => {
history.push(`${pathname}?${querystring.stringify(params, { sort: false })}`)
// params 只包含 defaultParams 中的字段
}
解决:
typescript
// 方案1:在defaultParams中添加这些字段(推荐)
const defaultParams = {
// ...其他字段
taskKey: '',
relatedId: '',
}
// 方案2:修改useSyncParams hook,保留额外参数
// (但可能影响其他页面,需谨慎)
坑3:后端获取域名的方式
问题 :前端用 window.location.host,后端用什么?
答案:
javascript
// 方式1:配置文件(当前使用)
const baseUrl = this.app.config.baseUrl || ''
// 方式2:从请求获取(更灵活)
const baseUrl = `${this.ctx.request.protocol}://${this.ctx.request.host}`
// 或
const baseUrl = this.ctx.request.origin
坑4:异步处理中的this上下文
问题 :异步函数中 this 可能为 undefined
解决:
javascript
// ✅ 项目风格:直接调用,不捕获this
this.processRecognitionTask(imageUrl, vendorType, redisKey, maintain_id)
// 如果出错,会在processRecognitionTask内部用this.ctx.throwValidateWarn处理
💡 经验总结
后端经验
-
Redis Key设计要简单
- ✅ 只用单个唯一标识(如
taskKey),不要组合多个参数 - ✅ 便于前端查询,不需要复杂的key生成逻辑
- ✅ 从输入中提取唯一标识(如文件名、ID等)
- ✅ 只用单个唯一标识(如
-
异步处理要"fire and forget"
- ✅ 不等待异步任务完成
- ✅ 错误处理在异步函数内部完成
- ✅ 符合项目现有代码风格
-
两个接口职责要清晰
create_task:创建任务(主动,会创建新任务)get_task_result:查询结果(被动,只查询不创建)
-
Redis数据结构要完整
- 存储任务相关的完整信息(type、input、related_id等)
- 便于前端直接使用,不需要额外查询
- 包含状态、结果、时间戳等元数据
前端经验
-
URL参数处理要统一
- ✅ 使用
querystring.parse(window.location.search) - ✅ 注意类型处理(可能是
string | string[] | null) - ✅ 使用类型断言或类型守卫
- ✅ 使用
-
Form和State要分清
- ✅ Form只能管理Form.Item中的字段
- ✅ 不在Form.Item中的组件用state管理
- ✅ 不要混用,避免无效操作
-
useSyncParams要小心
- ✅ 会重写URL,只保留defaultParams中的参数
- ✅ 额外参数需要在defaultParams中声明(即使为空)
- ✅ 避免URL参数被意外清除
-
页面打开时机要准确
- ✅ 等数据加载完成后再打开
- ✅ 通过useEffect监听数据变化
- ✅ 避免在数据未加载时操作
-
代码清理很重要
- ✅ 删除无意义的form操作
- ✅ 删除console.log和调试代码
- ✅ 保持代码整洁和可维护
通用经验
-
异步改造的核心
- 立即返回 → 后台处理 → 结果存储 → 通知用户
-
Redis的妙用
- 临时存储任务状态
- 跨请求共享数据
- 设置TTL自动清理
-
URL参数传递
- 简单直接,便于分享
- 刷新页面不丢失状态
- 但要处理好清除逻辑
-
代码风格一致性
- 参考项目现有代码
- 保持风格统一
- 便于团队协作
🎉 最终效果
用户体验提升
- 发起请求 → 立即返回"处理中",可以关闭页面
- 处理完成 → 收到通知,点击链接
- 自动打开 → 页面自动打开,处理结果自动填充
- 完美! ✨
技术指标
- ✅ 接口响应时间:从30-60秒 → <1秒
- ✅ 用户体验:从等待 → 异步处理
- ✅ 代码质量:逻辑清晰,职责分明
- ✅ 可维护性:高,符合项目风格
🚀 适用场景
什么时候用这个模式?
✅ 适合:
- 长耗时接口(>10秒)
- 网关有超时限制
- 用户不需要立即看到结果
- 可以通过通知告知用户
❌ 不适合:
- 短耗时接口(<5秒)
- 需要实时反馈
- 用户必须等待结果
📝 核心要点总结
- 两个接口分离:创建任务 vs 查询结果
- Redis存储状态:processing → completed/failed
- 异步处理:fire and forget模式
- URL参数传递:通过链接跳转并自动加载结果
- 前端状态管理:Form vs State要分清
Happy Coding! 🎊