17 KiB
设计文档:贩卖机移动端APP
概述
本设计文档描述贩卖机移动端APP的技术架构和实现方案。APP基于UniApp + Vue3(Composition API)技术栈开发,使用Pinia进行状态管理,vue-i18n实现多语言国际化,uQRCode生成二维码。APP通过封装的HTTP模块与后端REST API通信,使用JWT Bearer Token进行认证。支付集成通过UniApp原生插件调用Google Pay和Apple Pay。
架构
整体架构
graph TB
subgraph 移动端APP
Pages[页面层<br/>UniApp Pages]
Components[组件层<br/>可复用组件]
Stores[状态管理层<br/>Pinia Stores]
API[API层<br/>请求封装]
Utils[工具层<br/>i18n/支付/二维码]
end
subgraph 外部服务
Backend[后端API<br/>.NET REST API]
GooglePay[Google Pay]
ApplePay[Apple Pay]
end
Pages --> Components
Pages --> Stores
Stores --> API
API --> Backend
Utils --> GooglePay
Utils --> ApplePay
目录结构
mobile/
├── pages/ # 页面
│ ├── index/ # 首页
│ ├── login/ # 登录页
│ ├── membership/ # 会员页
│ ├── stamps/ # 节日印花页
│ ├── mine/ # 我的页
│ ├── points/ # 我的积分页
│ ├── coupons/ # 我的优惠券页
│ ├── agreement/ # 用户协议页
│ ├── privacy/ # 隐私政策页
│ └── about/ # 关于页
├── components/ # 可复用组件
│ ├── QrcodePopup.vue # 会员二维码弹窗
│ ├── CouponCard.vue # 优惠券卡片
│ ├── RedeemPopup.vue # 兑换确认弹窗
│ ├── GiftPointsPopup.vue # 赠送积分弹窗
│ ├── CouponGuidePopup.vue # 使用说明弹窗
│ └── LanguagePicker.vue # 语言选择器
├── stores/ # Pinia状态管理
│ ├── user.js # 用户状态
│ ├── membership.js # 会员状态
│ └── app.js # 应用全局状态(语言等)
├── api/ # API请求模块
│ ├── request.js # 请求封装(拦截器)
│ ├── user.js # 用户相关API
│ ├── membership.js # 会员相关API
│ ├── coupon.js # 优惠券相关API
│ ├── points.js # 积分相关API
│ └── content.js # 内容相关API
├── i18n/ # 多语言
│ ├── index.js # i18n配置
│ ├── zh-CN.js # 简体中文
│ ├── zh-TW.js # 繁体中文
│ └── en.js # 英文
├── utils/ # 工具函数
│ ├── payment.js # 支付封装
│ ├── qrcode.js # 二维码生成
│ ├── storage.js # 本地存储封装
│ └── auth.js # 认证工具
├── static/ # 静态资源
├── App.vue
├── main.js
├── pages.json
├── manifest.json
└── uni.scss
组件与接口
API请求模块 (api/request.js)
统一封装HTTP请求,自动处理Token注入、语言标识和错误拦截。
// 请求拦截器
// - 自动从本地存储读取JWT Token,添加到Authorization请求头
// - 自动从Pinia store读取当前语言,添加到Accept-Language请求头
// - 设置Content-Type为application/json
// 响应拦截器
// - 检查HTTP状态码,401时清除Token并跳转登录页
// - 解析响应体,根据success字段判断业务成功/失败
// - 网络错误时展示友好提示
接口签名:
/**
* 发起GET请求
* @param {string} url - 请求路径
* @param {object} params - 查询参数
* @returns {Promise<{success: boolean, data: any, message: string}>}
*/
function get(url, params)
/**
* 发起POST请求
* @param {string} url - 请求路径
* @param {object} data - 请求体
* @returns {Promise<{success: boolean, data: any, message: string}>}
*/
function post(url, data)
/**
* 发起DELETE请求
* @param {string} url - 请求路径
* @returns {Promise<{success: boolean, data: any, message: string}>}
*/
function del(url)
用户状态管理 (stores/user.js)
// Pinia Store: useUserStore
// state:
// token: string | null - JWT Token
// userInfo: object | null - 用户信息 { uid, nickname, isMember, ... }
// isLoggedIn: boolean - 计算属性,token !== null
//
// actions:
// login(phone, code, areaCode) - 调用登录API,存储token
// logout() - 调用登出API,清除状态
// fetchUserInfo() - 获取用户信息
// deleteAccount() - 注销账号
// clearAuth() - 清除本地认证信息
会员状态管理 (stores/membership.js)
// Pinia Store: useMembershipStore
// state:
// membershipInfo: object | null - 会员信息
// products: array - 会员商品列表
// subscriptionStatus: object - 订阅状态
//
// actions:
// fetchMembershipInfo() - 获取会员信息
// fetchProducts() - 获取商品列表
// purchase(productId, receipt) - 购买单月会员
// subscribe(productId, receipt) - 订阅会员
// fetchSubscriptionStatus() - 获取订阅状态
应用状态管理 (stores/app.js)
// Pinia Store: useAppStore
// state:
// locale: string - 当前语言 ('zh-CN' | 'zh-TW' | 'en')
//
// actions:
// setLocale(locale) - 切换语言,持久化到本地存储
// initLocale() - 初始化语言(从本地存储或系统语言)
支付封装 (utils/payment.js)
/**
* 统一支付接口,内部根据平台选择Google Pay或Apple Pay
* @param {object} params
* @param {string} params.productId - 商品ID
* @param {number} params.price - 价格
* @param {string} params.type - 'purchase' | 'subscribe'
* @returns {Promise<{success: boolean, receipt: string}>}
*/
function pay(params)
/**
* 检查支付环境是否可用
* @returns {Promise<boolean>}
*/
function isPaymentAvailable()
二维码生成 (utils/qrcode.js)
/**
* 生成会员二维码
* @param {string} token - 用户token
* @returns {string} base64编码的二维码图片
*/
function generateQRCode(token)
/**
* 创建带有效期管理的二维码实例
* @param {Function} getToken - 获取新token的异步函数
* @param {number} ttl - 有效期(毫秒),默认300000(5分钟)
* @returns {{ qrcodeData: Ref<string>, remainingTime: Ref<number>, refresh: Function, destroy: Function }}
*/
function useQRCode(getToken, ttl)
页面组件
首页 (pages/index/index.vue)
- 加载Banner轮播图(swiper组件)
- 展示功能入口图片网格
- 展示可兑换优惠券列表
- 包含会员二维码弹窗、使用说明弹窗、兑换确认弹窗
登录页 (pages/login/login.vue)
- 手机区号选择器 + 手机号输入框
- 验证码输入框 + 发送验证码按钮(含60秒倒计时)
- 协议勾选复选框 + 协议链接
- 登录按钮
会员页 (pages/membership/membership.vue)
- 会员宣传长图展示
- 会员商品信息展示
- 开通会员/订阅会员按钮(根据状态动态显示)
- 支付流程集成
节日印花页 (pages/stamps/stamps.vue)
- 印花Banner图展示
- 印花优惠券列表
- 兑换按钮(根据会员状态和兑换状态动态显示)
我的页 (pages/mine/mine.vue)
- 用户信息区域(头像、昵称、UID)
- 积分展示区域
- 功能菜单列表(优惠券、语言、协议、关于等)
- 赠送积分弹窗、语言选择弹窗、退出确认弹窗
我的积分页 (pages/points/points.vue)
- Tab切换(获取记录/使用记录)
- 积分记录列表(支持分页加载)
我的优惠券页 (pages/coupons/coupons.vue)
- Tab切换(可使用/已使用/已过期)
- 优惠券卡片列表(含状态标识)
数据模型
用户信息
{
uid: string, // 用户唯一标识
nickname: string, // 昵称,格式:"用户" + 6位随机数字
phone: string, // 手机号(脱敏)
isMember: boolean, // 是否为会员
memberExpireAt: string // 会员到期时间(ISO 8601)
}
会员商品
{
productId: string, // 商品ID
name: string, // 商品名称
price: number, // 价格
duration: number, // 生效时长(天)
type: string // 'monthly' | 'subscription'
}
优惠券
{
couponId: string, // 优惠券ID
name: string, // 优惠券名称
type: string, // 'thresholddiscount' | 'directdiscount'
thresholdAmount: number, // 满减门槛(满减券有值,抵扣券为null)
discountAmount: number, // 抵扣金额
requiredPoints: number, // 兑换所需积分
expireAt: string, // 到期时间
status: string // 'available' | 'used' | 'expired'
}
印花优惠券
{
stampCouponId: string, // 印花优惠券ID
name: string, // 名称
requiredPoints: number, // 兑换所需积分(可为0)
expireAt: string, // 到期时间
redeemed: boolean // 当前用户是否已兑换
}
积分记录
{
id: string, // 记录ID
type: string, // 'earn' | 'spend'
source: string, // 来源/使用方式描述
amount: number, // 积分数量
createdAt: string // 时间
}
Banner
{
id: string, // Banner ID
imageUrl: string, // 图片URL
linkType: string, // 'internal' | 'external'
linkUrl: string // 跳转链接
}
入口图片
{
id: string, // 入口ID
key: string, // 入口标识:'membership' | 'stamps' | 'qrcode' | 'guide'
imageUrl: string // 图片URL
}
正确性属性
属性是系统在所有有效执行中应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。
Property 1: 语言切换翻译正确性
对于任意支持的语言locale和任意翻译键key,切换到该locale后,翻译函数t(key)应返回该语言对应的非空文本,且不等于其他语言的翻译文本(除非原文相同)。
Validates: Requirements 1.2
Property 2: 语言设置持久化Round-Trip
对于任意支持的语言locale,设置语言后持久化到本地存储,再从本地存储读取并初始化,最终的locale应等于最初设置的值。
Validates: Requirements 1.4, 1.5
Property 3: 请求拦截器请求头注入
对于任意已存储的JWT Token和任意当前语言设置,通过请求模块发出的每个HTTP请求,其请求头中Authorization字段应为"Bearer {token}",Accept-Language字段应为当前语言标识。
Validates: Requirements 1.3, 2.6, 11.1
Property 4: 未勾选协议阻止登录
对于任意手机号和验证码输入,当协议勾选状态为false时,调用登录操作应被阻止且不发出API请求。
Validates: Requirements 2.3
Property 5: 登录Token存储
对于任意成功的登录API响应(包含token字段),登录操作完成后,本地存储中应包含该token值,且用户状态应为已登录。
Validates: Requirements 2.5
Property 6: 401响应自动清除认证
对于任意API请求,当响应状态码为401时,本地存储中的Token应被清除,用户状态应重置为未登录。
Validates: Requirements 2.8, 11.3
Property 7: Banner导航正确性
对于任意Banner配置项,当linkType为"internal"时点击应调用内部页面导航,当linkType为"external"时点击应调用外部链接打开,且目标URL应等于配置的linkUrl。
Validates: Requirements 3.2
Property 8: 二维码生成Round-Trip
对于任意有效的token字符串,生成二维码后解码应得到与原始token相同的字符串。
Validates: Requirements 4.1
Property 9: 会员按钮状态正确性
对于任意会员状态组合(非会员/单月会员/订阅会员),会员页按钮的显示状态应满足:非会员时显示"开通会员"可点击按钮;单月会员时隐藏单月按钮、显示"订阅会员"可点击按钮;订阅会员时显示"已购买订阅会员"灰色不可点击按钮。
Validates: Requirements 5.5, 5.8, 5.9
Property 10: 印花优惠券兑换状态显示
对于任意印花优惠券列表,redeemed为true的优惠券应显示"已兑换"灰色不可点击按钮,redeemed为false的应显示可点击的"兑换"按钮。
Validates: Requirements 6.6
Property 11: 分页加载参数正确性
对于任意初始页码和连续N次加载更多操作,第i次请求的page参数应等于初始页码+i。
Validates: Requirements 8.4
Property 12: 优惠券Tab状态参数正确性
对于任意Tab切换操作(可使用/已使用/已过期),API请求中的status参数应分别对应"available"、"used"、"expired"。
Validates: Requirements 9.2
Property 13: 优惠券状态标识显示正确性
对于任意优惠券列表,status为"used"的优惠券应显示"已使用"标识,status为"expired"的优惠券应显示"已过期"标识,status为"available"的优惠券不显示状态标识。
Validates: Requirements 9.3, 9.4
Property 14: API响应统一处理
对于任意API响应体,当success为true时应返回data数据,当success为false时应抛出或返回包含message的错误信息。
Validates: Requirements 11.2
Property 15: 网络错误友好提示
对于任意网络错误(超时、断网、服务器错误),请求模块应捕获错误并调用提示函数展示错误信息,而非抛出未处理异常。
Validates: Requirements 11.5
Property 16: 支付渠道平台选择
对于任意支付请求,当平台为Android时应调用Google Pay接口,当平台为iOS时应调用Apple Pay接口。
Validates: Requirements 12.2, 12.3
错误处理
API错误处理
| 错误场景 | 处理方式 |
|---|---|
| 401 未授权 | 清除本地Token,跳转登录页 |
| 网络超时 | 展示"网络连接超时,请重试"提示 |
| 网络断开 | 展示"网络连接失败,请检查网络设置"提示 |
| 500 服务器错误 | 展示"服务器繁忙,请稍后重试"提示 |
| 业务错误(success=false) | 展示后端返回的message信息 |
支付错误处理
| 错误场景 | 处理方式 |
|---|---|
| 支付取消 | 关闭支付弹窗,不做额外处理 |
| 支付失败 | 展示"支付失败,请重试"提示 |
| 支付环境不可用 | 展示"当前设备不支持该支付方式"提示 |
| 支付成功但后端确认失败 | 展示"支付已完成,会员状态稍后更新"提示 |
业务错误处理
| 错误场景 | 处理方式 |
|---|---|
| 积分不足兑换优惠券 | 弹出"积分不足,无法兑换"提示 |
| 优惠券已下架 | 弹出"优惠券已下架"提示 |
| 积分不足赠送 | 弹出"剩余积分不足,无法赠送"提示 |
| 未勾选协议登录 | 弹出"请阅读并同意协议"提示 |
| 验证码发送频率限制 | 展示倒计时,按钮不可点击 |
测试策略
测试框架
- 单元测试:Vitest
- 属性测试:fast-check(配合Vitest)
- 组件测试:@vue/test-utils
单元测试
单元测试用于验证具体示例、边界情况和错误条件:
- API模块:验证各接口调用参数和URL正确性
- Store模块:验证状态变更逻辑(登录/登出/语言切换)
- 工具函数:验证支付渠道选择、存储读写
- 组件:验证条件渲染(登录/未登录状态、会员/非会员状态)
属性测试
属性测试用于验证跨所有输入的通用属性,每个属性测试至少运行100次迭代:
- 每个属性测试必须引用设计文档中的属性编号
- 标签格式:Feature: mobile-app, Property {number}: {property_text}
- 使用fast-check库生成随机测试数据
- 每个正确性属性由一个独立的属性测试实现
测试覆盖重点
| 模块 | 单元测试 | 属性测试 |
|---|---|---|
| 请求拦截器 | 基本请求示例 | Property 3, 6, 14, 15 |
| 多语言模块 | 语言文件完整性 | Property 1, 2 |
| 用户认证 | 登录/登出流程 | Property 4, 5 |
| 首页 | Banner渲染、入口导航 | Property 7 |
| 二维码 | 生成和显示 | Property 8 |
| 会员页 | 按钮状态 | Property 9 |
| 印花页 | 兑换状态 | Property 10 |
| 积分页 | Tab切换 | Property 11 |
| 优惠券页 | Tab切换、状态标识 | Property 12, 13 |
| 支付模块 | 支付流程 | Property 16 |