从PRD拆解旅游小程序:订单状态机、图文表与地址快照设计
2026/9/19 0:59:42 网站建设 项目流程

简介:这是一份「红人」旅游小程序的产品需求文档,面向产品经理、小程序开发者和旅游 O2O 项目相关成员,核心是解决“能看、能买、能传播”的全流程需求。文档从全局功能逻辑、订单流程与业务角色出发,完整梳理了资讯、商品、用户、订单的信息结构,并用首页商城、商品详情、选择套餐、信息填写、支付、订单等原型图覆盖用户从浏览到下单的全链路;同时标明订单待支付超3小时失效、订单的6种状态、未登录点击客服需先登录等关键规则。压缩包内为1个docx文件,包体大小5.27MB,目录层级清晰,便于按模块查阅,目前已有258人学习。除原型与流程外,文末的排期草稿和7句真言还总结了模块粒度、MECE原则、高内聚低耦合、服务器端逻辑边界等实战经验,并提醒项目启动会议与团队使命感的重要性,可直接用于PRD撰写和评审,帮助团队少踩坑。

1. 为什么一份旅游小程序PRD值得当架构文档读

做微信小程序商城的人,通常把PRD当需求翻译稿,翻完就画原型。但拆「红人」这份O2O旅游小程序PRD时发现,真正值钱的不是页面模块,而是藏在注释里的决策:订单状态有6种且把“预约失败”单独列出,待支付3小时自动失效;资讯内容不直接存富文本,而是用图文表与文章1对多;订单地址不依赖用户收货地址,而是地址库id+买家地址快照。这三条直接决定数据表、状态机、定时任务和接口怎么设计。

对后端或全栈工程师来说,这是一份现成的系统设计题:在需求模糊、排期紧的旅游小程序里,把“能看、能买、能传播”翻译成高内聚低耦合的模块边界。对新人,它也提供了一个完整最小闭环:内容展示、选套餐、填信息、支付、订单状态流转。

后文按产品信息结构、订单状态机、首页动态配置、排期落地四个方向拆,重点回答:表怎么建、状态怎么转、首页模块怎么配置、第一版先上线什么。

2. 从功能结构到表结构:资讯图文表、订单地址快照与模块边界

很多小程序商城的第一版会直接塞一个富文本编辑器,让运营在后台粘贴排版好的文章。这份PRD却选择把文章内容拆成“图文表”,每组图文有标题、描述、图、标注四个可选字段。初看是前端能力不足的妥协,细看是内容结构化的正确决定。富文本存进数据库带来的问题很多:编辑器在不同端渲染不一致,用户复制内容可能带进script标签,搜索和摘要提取只能靠正则。图文表牺牲了排版自由度,换来了可控的C端展示和可控的后台录入。

2.1 图文表为什么是1对多而不是富文本JSON

PRD原文里的逻辑是:文章与图文1对多,一组图文包括标题、描述、图、标注,四个字段都可选填。落到数据库就是两张表:article保存文章元信息,article_content保存正文内容块。

CREATE TABLE article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL COMMENT '资讯标题', category_id BIGINT COMMENT '分类,对应资讯城市/攻略等', status TINYINT DEFAULT 0 COMMENT '0草稿 1发布', publish_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE article_content ( id BIGINT PRIMARY KEY AUTO_INCREMENT, article_id BIGINT NOT NULL COMMENT '关联article.id', block_title VARCHAR(255) COMMENT '图文块标题,可空', block_desc TEXT COMMENT '图文块描述,可空', image_url VARCHAR(512) COMMENT '图片地址,可空', annotation VARCHAR(512) COMMENT '标注,可空,用于角标说明', sort_no INT DEFAULT 0 COMMENT '同文章内排序', KEY idx_article_sort (article_id, sort_no) );

article表只负责文章元信息,标题、分类、发布时间、状态;真正在资讯详情页滚动展示的每个段落都在article_content里。sort_no控制顺序,前端遍历时按article_id和sort_no一次查出来,直接渲染,不需要在小程序端做富文本解析。四个字段都允许为空,意味着一个图文块可以只有图没有文字,也可以只有文字没有图,封面图、说明性角标都能落在这里。查询时用一条SQL按article_id取列表,idx_article_sort保证排序稳定。

如果换作富文本JSON,业务上每个“块”的字段变成JSON里的动态key,数据库无法对key做约束;一旦微信小程序端组件不支持某个标签,还要自己写解析器过滤。图文表把这个复杂度转移到了后台录入端:运营只能按固定四字段录入,前端组件就可以写死。

2.2 订单地址快照:为什么不能信任用户收货地址

PRD在商品、用户、订单信息结构图里写了一句容易被忽略的话:订单主表中存储地址库id和买家具体地址组合成购物地址,不依赖用户收货地址的信息,因为用户的收货地址是可能发生人为的修改的。这句话是在说“快照”。用户维护的收货地址是可变资源,订单创建时的地址是业务事实。如果订单只存address_id,用户修改默认地址后,历史订单的配送信息就会跟着变,电子票可能无所谓,但活动商品里有些纸质票需要寄送,出问题就是客诉。

实现上在订单表里冗余两个字段:

CREATE TABLE orders ( order_id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT '买家', product_id BIGINT NOT NULL, package_id BIGINT NOT NULL COMMENT '选择的套餐', address_id BIGINT COMMENT '地址库id,仅用于后台追溯', address_snapshot VARCHAR(512) COMMENT '下单时的完整收货地址快照', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2卖家确认成功 3预约失败 4已失效 5已取消', expire_at DATETIME COMMENT '待支付过期时间', created_at DATETIME DEFAULT CURRENT_TIMESTAMP );

下单时把当前用户选中的收货地址串成address_snapshot写入,后续用户改地址不影响这张单。address_id的存在意义是让客服在后台看到“这张单当初选的是哪个地址库条目”,不参与下单状态的判断。这样写还能避免联表查询:订单列表、订单详情都只需要orders单表,性能更可控。

注意address_snapshot要存纯文本还是JSON。如果后续有省市区code需要统计,建议存JSON:{"province":"浙江省","city":"杭州市","district":"西湖区","detail":"文三路100号"}。如果只是打印面单,纯文本加换行符就够了。我一般会把省市区编码也冗余进去,方便出报表按地区聚合,代价仅仅是多几个字段。

2.3 用MECE把页面模块切成可独立上线的服务

PRD总结里写到:模块之间高内聚低耦合,让每个模块尽可能独立完成某个特定的子功能,模块与模块之间的接口尽量少而简单。这段话如果停留在原型图上是口号,落到代码里就是服务边界和分包边界。资讯模块、商品模块、订单模块、用户模块,先画信息结构图再画页面流程图,目的就是强制让数据模型不跟着页面走。

业务域核心实体建议服务第一批是否上线
内容article / article_content资讯服务可视作引流,可后置
商品product / package / inventory商品服务必须
交易orders / payment_callback订单服务必须
用户user / address用户服务必须

页面是组合,服务是本质。首页商城同时展示商品和资讯,如果后端接口按首页来设计,后续小程序端增加一个新页面就得多一个聚合接口。更合适的做法是:商品服务只出商品,内容服务只出内容,首页接口做编排。PRD里“Banner、主题推荐、攻略资讯:根据后台参数跳转不同页面”就是在约束这个编排层,而不是让每个业务表都塞一个position字段。这一点对微信小程序尤其重要,因为分包大小有限,页面多了以后,服务端接口是否按业务域收敛,直接决定前端能拆多少个分包。

3. 订单状态机与3小时自动失效:把交易状态调成可校验的有限集合

旅游订单和普通电商订单最大的区别是:支付完成后并不直接发货,中间还有“卖家确认成功”和“预约失败”两个环节。这份PRD的订单状态有6种:已支付、待支付、卖家确认成功、预约失败、订单已失效、订单已取消。如果建模时图省事,只保留“待支付/已支付/已取消”三个状态,后续供应商资源不足、用户改期、客服代操作都会变成零散的if/else,最后谁都说不清一笔订单现在到底处于什么阶段。

3.1 六个状态怎么定义才能不打架

看PRD时不要按页面流程理解这6个状态,要按订单生命周期理解。“待支付”是创建订单后的起始态,“已支付”是收到支付回调后的状态,“卖家确认成功”是后台确认资源后的终态,“预约失败”是资源不足或供应商取消后的终态,“订单已失效”是系统超时触发,“订单已取消”是用户或客服主动关闭。后两个看似都是“没买成”,但统计口径完全不同,不能合并。

状态值枚举名含义是否终态
0PENDING_PAYMENT待支付
1PAID已支付
2CONFIRMED卖家确认成功
3BOOKING_FAILED预约失败
4EXPIRED订单已失效
5CANCELLED订单已取消

待支付是唯一可被取消和失效的节点。已支付之后,系统只能把它变成确认成功或预约失败。这里最容易踩的坑是:把“预约失败”做成“已支付”下的一种备注,而不是独立状态。一旦做成备注,售后对账、财务退款、客服搜索都拿不到结构化数据,只能从操作日志里翻,成本极高。

3.2 用状态转移表约束越权操作

状态机写代码的核心不是if/else,而是用一个转移表挡住非法流转。比如一个已经“卖家确认成功”的订单,理论上不能由用户直接改成“已支付”;一个“预约失败”的订单不允许再被确认成功。把这个表写在枚举里,测试就能按表生成用例。

enum OrderStatus { PENDING_PAYMENT = 'PENDING_PAYMENT', PAID = 'PAID', CONFIRMED = 'CONFIRMED', BOOKING_FAILED = 'BOOKING_FAILED', EXPIRED = 'EXPIRED', CANCELLED = 'CANCELLED', } const ALLOWED_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = { [OrderStatus.PENDING_PAYMENT]: [OrderStatus.PAID, OrderStatus.EXPIRED, OrderStatus.CANCELLED], [OrderStatus.PAID]: [OrderStatus.CONFIRMED, OrderStatus.BOOKING_FAILED], [OrderStatus.CONFIRMED]: [], [OrderStatus.BOOKING_FAILED]: [], [OrderStatus.EXPIRED]: [], [OrderStatus.CANCELLED]: [], }; function changeOrderStatus(orderId: string, from: OrderStatus, to: OrderStatus) { if (!ALLOWED_TRANSITIONS[from].includes(to)) { throw new Error(`非法状态流转: ${from} -> ${to}`); } // UPDATE orders SET status = ? WHERE order_id = ? AND status = ? // 同时记录 status_log,要求返回受影响行数为1 }

实际更新语句必须在WHERE里带from,防止两个请求同时操作同一订单:一个改成已支付,另一个改成取消,只有一个能成功。受影响行数为0说明订单已不在原状态,直接抛异常或重试。每一次状态变更都应当写status_log表,记录操作人、操作时间、变更前后状态。这样客服后台点错按钮时,能顺着日志还原动作,而不是靠猜。

3.3 超时失效用定时任务还是延迟队列

PRD规定待支付超过3小时自动失效。最先想到的做法是定时任务每分钟扫一次订单表,把created_at小于当前时间减3小时且status=0的改成失效。但扫描方式在高并发下会拖累主库。常见做法是先用延迟队列或Redis过期key做第一道淘汰,再用定时任务兜底。比如下单时把order_id写进Redis,key为order:expire:{orderId},EXPIRE 10800秒;在消费者侧订阅键过期事件,键过期后触发失效。不过Redis键过期事件并不完全准时,集群模式下还有订阅延迟,所以要么容忍几分钟误差,要么仍用定时任务兜底。

-- 只处理待支付且超过超时时间的订单,避免大事务 UPDATE orders SET status = 4 WHERE status = 0 AND expire_at <= NOW() LIMIT 500;

用expire_at代替created_at比较,expire_at在下单时等于created_at + INTERVAL 3 HOUR。这样即使改了超时时长或用户触发延长支付,历史订单也不受影响。LIMIT 500控制一次更新的行数,避免长事务。定时任务执行完后,再对这批订单做库存回滚、发送失效通知。库存回滚和状态更新不建议放在同一个事务里;超时订单量小时无所谓,量大时状态更新占用的行锁时间会拖慢同一商品的其他下单请求。正确的顺序是:先更新订单状态,再通过消息队列异步回滚库存。

小程序端展示“订单即将过期”倒计时时,要用后端下发的expire_at和服务器时间戳做差,不要用本地时间。用户改了手机本地时间,倒计时会失真,但expire_at是数据库里的绝对时间,不受客户端影响。

4. 商城主页6模块动态配置:Banner、icon、猜你喜欢共用一套跳转协议

商城主页有6个模块:Banner、icon模块、新品&独家、主题推荐、攻略资讯、猜你喜欢。页面逻辑第一句是:Banner、主题推荐、攻略资讯根据后台参数跳转不同页面,类型如下:资讯文章、商品详情、H5活动。这意味着一份点击配置要能被至少三个模块复用。如果每个模块单独建一套跳转配置,后台要维护三份,前端要写三套路由,加一种跳转类型就要改三个页面。

4.1 一个actionType管住四种目标页面

正确的做法是给每个可运营位定义一个统一的action对象,页面渲染时只读action,不关心当前模块具体是Banner还是主题推荐。action字段固定为“类型+目标id+扩展参数”三段式。

action.type目标页必填参数示例
ARTICLE_DETAIL资讯详情targetId142
PRODUCT_DETAIL商品详情targetIdp1001
H5_ACTIVITYH5活动params.urlhttps://xxx
{ "id": "banner_001", "module": "BANNER", "title": "暑期海岛特辑", "action": { "type": "PRODUCT_DETAIL", "targetId": "p1001", "params": {} } }

前端拿到配置后通过switch匹配跳转函数,不需要关心当前是Banner还是主题推荐。新增一种跳转类型时,只需要增加枚举、路由表、目标页三个地方,不会影响其他模块。这里顺带解决一个热搜词场景:小程序动态设置标题。资讯详情页打开时,根据配置里的title字段调用wx.setNavigationBarTitle,把顶部导航栏标题改成文章标题,而不是用固定标题。这个字段应该和action平级,因为它属于页面展示配置,不属于跳转协议。

4.2 icon模块固定4个,剩下的交给后台配置

PRD里对icon模块的要求很克制:固定4个icon(国内、海外、品牌、体验),其他的后台给就显示,不给就隐藏,统一跳转筛选列表,根据分类字段查询列表信息,页面样式统一。这个设计避免了每次营销节点都要发版的尴尬。国内、海外、品牌、体验是旅游平台的四个核心分类,固定在客户端保证首页第一屏永远看得到;其余促销icon由后台下发,运营可以随时增减。

{ "icons": [ { "key": "domestic", "name": "国内", "fixed": true, "categoryId": 101 }, { "key": "overseas", "name": "海外", "fixed": true, "categoryId": 102 }, { "key": "brand", "name": "品牌", "fixed": true, "categoryId": 103 }, { "key": "experience", "name": "体验", "fixed": true, "categoryId": 104 } ] }

fixed为true表示前端写死,后台不可删除;categoryId决定跳筛选列表时的查询条件。后台新增的非固定icon也走同一套结构,区别是fixed字段由接口返回,前端渲染时先放本地固定icon,再按后台排序追加动态icon。如果用uniapp开发微信小程序,这个合并逻辑可以放在页面onLoad里统一处理,避免每个页面重复判断。

这样做还留了一个扩展空间:以后“品牌”或“体验”如果要从固定变成动态,只需把客户端写死的部分去掉,改为从配置接口读取,页面结构不用动。这就是PRD里“产品模型和业务模型不要混在一起”的实际用法。

4.3 页面参数校验与异常跳转

后台配置一旦开放,就会有运营填错targetId。Banner点进去是空白页,用户感知很差。至少要做三层校验:第一层,配置后台在保存时校验type与targetId是否匹配,比如PRODUCT_DETAIL的targetId必须能被商品服务查到;第二层,首页配置下发给小程序时,网关对action对象做schema校验;第三层,前端路由跳转前判断目标页面是否存在。

function handleAction(action: AppAction) { switch (action.type) { case 'ARTICLE_DETAIL': wx.navigateTo({ url: `/pages/article/detail?id=${action.targetId}` }); break; case 'PRODUCT_DETAIL': wx.navigateTo({ url: `/pages/product/detail?id=${action.targetId}` }); break; case 'H5_ACTIVITY': wx.navigateTo({ url: `/pages/webview/index?url=${encodeURIComponent(action.params.url)}` }); break; default: wx.showToast({ title: '配置错误', icon: 'none' }); } }

PRODUCT_DETAIL跳转商品详情页,id从targetId取;H5_ACTIVITY的url必须显式传进params并做encodeURIComponent,否则web-view不接受带中文或特殊字符的url;ARTICLE_DETAIL页面优先从本地缓存读取资讯数据,如果targetId不存在,应该提示“内容已下线”而不是白屏。开发调试阶段,可以直接抓取首页配置接口的返回JSON,核对action字段类型。常见问题是后端把targetId写成字符串,前端用number比较,导致跳不过去。这类问题在联调文档里标清楚字段类型,比事后看日志更快。

5. 拿PRD倒推排期:先跑通“能看、能买、能传播”的最小闭环

PRD里的排期草稿图没有具体日期,但有明确提示:红字部分可在产品1.0.0版本之后考虑分批上线。结合原型图看,第一版应该最先做首页商城、商品详情、选择套餐、信息填写、支付、订单列表,把“能买”跑通;“能看”先用资讯的图文列表和详情承接;“能传播”核心是客服会话和订单分享,不需要等后台管理系统全部完成。资讯、我的、后台管理系统这些标注“未上传”的部分,适合放到1.0.0之后的迭代里。

一个可操作的排期是:第一周完成订单状态机和订单表,第二周完成商品与套餐选择,第三周完成首页配置下发和跳转协议,第四周做支付回调、超时失效联调。开发顺序和页面顺序相反,因为交易链路的状态流转是地基。PRD里的页面流程图正好可以反向生成测试用例:支付成功、支付超时、预约失败这三个分支,每一张页面循环都可以对应到状态机的某一条迁移。

迭代版本功能依赖
0.9首页商城、商品详情、选套餐、填信息、支付订单状态机、支付回调
1.0.0订单列表、超时失效、客服会话微信客服消息接口
1.0.1+资讯城市、我的页面、后台管理系统图文表管理后台

验收时,不要把“首页看起来不错”当通过标准。把甲方那句“能看、能买、能传播”拆成可执行检查项:能看,指Banner、主题推荐、攻略资讯都能按后台配置跳转;能买,指从商品详情到支付成功全链路订单状态正确流转,待支付订单3小时自动失效;能传播,指用户可以在小程序内发起客服会话,并能通过分享卡片打开对应商品页。每一条都写进测试用例,开发自测、产品验收共用同一份清单。

最后留一个拆PRD时屡试不爽的技巧:拿到文档先不要画原型或写代码,把里面所有“ps:”和红字单独抽出来。它们几乎都是第一版不做但第二版要兼容的约束,订单地址快照、活动商品纸质票地址、资讯图文表,都是低频但关键的功能。先在数据模型层解决这些问题,后续迭代才不需要改表迁移。PRD里的页面线框图会过时,订单状态和地址快照这两个决策,代码上线后三年内都不会过时。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询