摘要:Mall4j 包含管理员、买家两套独立后端 API。管理员端 8085、买家端 8086 看似都是操作数据,但登录机制、权限模型、数据可见性完全不同。两端操作会互相影响:管理员下架商品,买家购物车该如何处理;管理员禁用会员,买家 Token 是否立即失效。只单独测试一端,会漏掉大量双端联动缺陷。
前言:很多人做测试,登录哪个账号方便就用哪个------结果买家端的并发、幂等、会话问题一个都没覆盖。我之前也这么干,结果上线第三天就出了超卖问题,赔了好几个订单 。双端系统的测试不是"多做一遍",而是"理解两种角色 + 设计两套策略 + 验证两端联动"。
一、一个系统,两个端口
Mall4j 是前后端分离的电商系统。它有两个后端服务:
| 服务 | 端口 | 面向谁 | 主要功能 |
|---|---|---|---|
| yami-shop-admin | 8085 | 管理员 | 商品、订单、会员、门店管理 |
| yami-shop-api | 8086 | 买家 | 浏览、加购、下单 |
很多人第一反应是:"不就是换个端口吗?能差多少?"
差很多。
| 管理员端(8085) | 买家端(8086) |
|---|---|
| 权限 RBAC + 菜单 | 认证 Token + Redis |
| 视野 查看全部数据 | 视野 仅查看自身数据 |
| 防护 权限隔离 | 防护 会话生命周期 |
| 能力 后台管理能力 | 场景 完整用户消费旅程 |
| 重点 数据一致性、越权校验 | 重点 并发、幂等、会话失效 |
双端对比------表面端口不同,本质是"权限模型 + 数据可见性 + 测试重点"的三重差异。
二、差异 1:登录机制完全不同
管理员端:RBAC + 角色权限
text
登录 → 查询用户的角色 → 加载角色的菜单 → 前端只显示有权限的菜单
核心是"角色"。
买家端:Token + 会话
text
登录 → 生成 Token → 存 Redis → 每次请求带 Token → 校验 Token
核心是"会话"。
测试差异:
| 维度 | 管理员端 | 买家端 |
|---|---|---|
| 测试重点 | 权限隔离 | 会话生命周期 |
| 典型用例 | 受限角色看不到超级管理员的菜单 | Token 过期后接口返回 401 |
| 典型缺陷 | RBAC 配置错误 | Token 未及时失效 |
结论:同样是"鉴权测试",一个是"角色能不能看到",另一个是"会话有没有过期"。
三、差异 2:同一个业务,语义完全不同
以"会员管理"为例。
管理员视角:
text
GET /admin/user/page → 查看所有会员
PUT /admin/user/{id}/status → 禁用/启用会员
买家视角:
text
GET /p/myInfo → 查看自己的信息
PUT /p/myInfo → 修改自己的昵称/头像
同一个"用户",两套完全不同的接口。
| 维度 | 管理员端 | 买家端 |
|---|---|---|
| 数据可见性 | 全部用户 | 只有自己 |
| 操作权限 | 改别人 | 只能改自己 |
| 认证方式 | 角色 + 菜单 | Token |
| 返回字段 | 更全(含 status 等) | 更少(隐私) |
结论 :设计会员模块的测试方案,要按两类角色拆分测试思路:管理员 负责会员数据管理,普通会员仅可管理自身信息
四、差异 3:登录加密方式相同,但有个坑
管理员端和买家端,都用了 AES 加密密码:
python
# 管理员登录
passWord = encrypt_password("123456")
# 买家登录
passWord = encrypt_password("123456")
加密逻辑完全一样 ------AES/ECB/Pkcs7 + 密钥 -mall4j-password。
但有个坑------加密时拼了时间戳。
Mall4j 前端加密逻辑是:
ini
// 以下为加密逻辑伪代码,非真实实现
const ts = Date.now(); // 毫秒时间戳
const plain = ts + password; // 时间戳 + 明文密码
const encrypted = AES.encrypt(plain, key); // 再 AES 加密
这意味着:
- 同一个密码,每次加密结果都不一样(时间戳不同)
- Python 复现时,也必须拼时间戳,否则后端解密失败
我在 Postman 调接口时的实战技巧:
以前每次手动改时间戳特别麻烦,后来直接把这段 Python 加密逻辑封装成了公共函数,所有登录请求自动生成加密后的密码,再也没出现过解密失败的问题。
python
def encrypt_password(password: str) -> str:
ts = str(int(time.time() * 1000)) # 毫秒时间戳
plain = (ts + password).encode("utf-8")
cipher = AES.new(AES_KEY, AES.MODE_ECB)
encrypted = cipher.encrypt(pad(plain, AES.block_size))
return base64.b64encode(encrypted).decode("utf-8")
结论:这是我在复现加密时踩过的第一个坑------一开始只加密密码,没拼时间戳,后端一直返回"密码错误"。加上时间戳后才通。
五、差异 4:数据库分两层库存,容易踩坑
Mall4j 的库存分两层:
| 表 | 字段 | 用途 |
|---|---|---|
| tz_prod | total_stocks | 下单校验用 |
| tz_sku | stocks | SKU 总库存(展示用) |
| tz_sku | actual_stocks | SKU 实际可售库存 |
我本地这个版本,下单时校验的是 tz_prod.total_stocks。
测试踩坑:
某次测试改了 tz_sku 库存为 0,但下单还能成功------因为下单不查 tz_sku。
改库存的正确姿势:
sql
UPDATE tz_prod SET total_stocks = 1000 WHERE prod_id = 75;
UPDATE tz_sku SET stocks = 1000, actual_stocks = 1000 WHERE sku_id = 402;
两张表都要改。
版本避坑提示 : 我查过 Mall4j 的几个历史版本,有的版本下单校验的是 sku 表的库存,不是 prod 表。别硬抄我的 SQL,先打个后端 SQL 日志看一眼,比瞎改数据库靠谱多了。
结论:测试失败时,先看后端 SQL 日志,确认它读的是哪张表、哪个字段,而不是猜。
六、差异 5:买家端独有的 4 个测试场景
管理员端测"管理能力",买家端测"用户旅程"。买家端有 4 个管理员端不存在的场景:
场景 1:并发(下单扣库存,不是加购)
先纠正一个常见误区:加购一般不扣库存,超卖是下单扣库存才有的问题。所以要测并发,要测"并发下单",不是"并发加购"。
python
def test_concurrent_submit_order(buyer_headers):
"""
并发下单测试:10 个线程同时下单同一商品,库存 = 1
断言:成功订单数 <= 库存数,且 DB 库存未被扣成负数
"""
# 1. 设置库存 = 1
DBHelper().execute(
"UPDATE tz_prod SET total_stocks = 1 WHERE prod_id = 75"
)
DBHelper().execute(
"UPDATE tz_sku SET stocks = 1, actual_stocks = 1 WHERE sku_id = 402"
)
# 2. 并发下单
def submit_one(_):
try:
cart = BuyerCart(buyer_headers)
cart.add_to_cart(sku_id=402, prod_id=75, count=1)
basket_id = cart.get_first_basket_id()
order = BuyerOrder(buyer_headers)
order.confirm_order(basket_ids=[basket_id], addr_id=3)
return order.submit_order(shop_id=1).get("code")
except Exception as e:
return f"EX: {e}"
with ThreadPoolExecutor(max_workers=10) as ex:
results = list(ex.map(submit_one, range(10)))
success = sum(1 for r in results if r == "00000")
# 3. 断言:成功订单数 <= 库存数
assert success <= 1, f"超卖风险:成功 {success} 单,库存仅 1"
# 4. 断言:DB 库存未被扣成负数
row = DBHelper().query_one(
"SELECT total_stocks FROM tz_prod WHERE prod_id = 75"
)
assert row["total_stocks"] >= 0, "库存被扣成负数"
⚠️我实际测试时的坑 :一开始写的是"10 线程并发加购",结果 10 个都成功,误以为"超卖"。后来才发现------加购不扣库存,超卖只在"下单"阶段才会出现。改成"并发下单"才是对的。
场景 2:幂等(区分"重复提交"和"想买两单")
幂等的本质是"防止同一个请求因网络重发被重复执行"。
要区分两个场景:
| 场景 | 前端行为 | 系统预期 |
|---|---|---|
| 同一预订单被重复提交 | 用户连点"提交" | 只创建 1 个订单 |
| 用户真想买两单 | 重新走"确认订单"生成新预订单 | 创建 2 个订单 |
我的测试用例验证的是第一个场景:
python
def test_submit_order_idempotent(buyer_headers):
"""
幂等测试:同一预订单重复提交
"""
cart = BuyerCart(buyer_headers)
cart.delete_all()
# 加购 + 确认订单(生成一个预订单)
cart.add_to_cart(sku_id=402, prod_id=75, count=1)
basket_id = cart.get_first_basket_id()
order = BuyerOrder(buyer_headers)
order.confirm_order(basket_ids=[basket_id], addr_id=3)
# 第 1 次提交
r1 = order.submit_order(shop_id=1)
assert r1["code"] == "00000"
# 第 2 次提交(同一预订单,模拟连点)
r2 = order.submit_order(shop_id=1)
assert r2["code"] != "00000", "重复提交应被拒绝"
# 断言:数据库只 +1 个订单
count = DBHelper().query_one(
"SELECT COUNT(*) AS cnt FROM tz_order WHERE remarks='自动化测试下单'"
)["cnt"]
assert count == 1, f"重复提交创建了 {count} 个订单"
如果用户真想买两单呢?
他需要重新走"确认订单"流程生成一个新的预订单,然后提交。这样前后两次是不同的预订单,系统会分别处理,创建 2 个订单。这是正确行为。
结论 :Mall4j 用"预订单一次性消费 "实现幂等------confirm_order 生成一个有时效的预订单(存 Redis),submit_order 消费掉它。第二次提交时预订单已被消费,直接拒绝。
场景 3:完整用户旅程
python
def test_full_user_journey(buyer_headers):
"""完整链路:浏览 → 加购 → 确认 → 提交 → DB 断言"""
# 1. 浏览商品
product = BuyerProduct(buyer_headers).prod_list_by_tag(tag_id=1)
# 2. 加购
BuyerCart(buyer_headers).add_to_cart(sku_id=402, prod_id=75, count=1)
# 3. 确认订单
basket_id = BuyerCart(buyer_headers).get_first_basket_id()
BuyerOrder(buyer_headers).confirm_order(
basket_ids=[basket_id], addr_id=3
)
# 4. 提交订单
submit = BuyerOrder(buyer_headers).submit_order(shop_id=1)
# 5. DB 断言
row = DBHelper().query_one(
"SELECT status FROM tz_order WHERE order_number=%s",
(submit["data"]["orderNumbers"],)
)
assert row["status"] == 1 # 待支付
这个场景验证的是买家端完整链路是否通顺,任何一步断裂都应该被用例捕获。
场景 4:会话过期
python
def test_buyer_token_expired():
expired_token = "xxx_expired"
resp = BuyerCart({"Authorization": expired_token}).get_cart_info()
assert resp["code"] != "00000"
管理员端的 RBAC 角色不会"过期",这是买家端独有的。
七、差异 6:双端数据一致性(最容易漏)
这是双端系统里最容易被漏掉、也最能体现测试水平的一节。
⚠️ 快速排查清单:把最容易踩的 4 个联动坑列成检查点:
1、管理员下架商品后,买家能否继续结算?
2、管理员改价后,买家端显示和下单金额是否一致?
3、管理员禁用会员后,买家 Token 是否立即失效?
4、管理员改库存后,买家端展示和下单校验是否一致?
场景 1:管理员下架商品 → 买家端购物车该怎么处理?
python
def test_admin_off_shelf_product_buyer_cart(api_session, buyer_headers):
"""管理员下架商品后,买家端购物车应无法结算"""
# 1. 买家先加购
cart = BuyerCart(buyer_headers)
cart.add_to_cart(sku_id=402, prod_id=75, count=1)
# 2. 管理员下架商品
ProductApi(api_session).update_status(prod_id=75, status=0)
# 3. 买家确认订单,应该失败
basket_id = cart.get_first_basket_id()
resp = BuyerOrder(buyer_headers).confirm_order(
basket_ids=[basket_id], addr_id=3
)
assert resp["code"] != "00000", "下架商品不该能结算"
场景 2:管理员改价格 → 买家端显示和下单价格是否一致?
python
def test_admin_price_change_sync(api_session, buyer_headers):
"""管理员改价后,买家端看到的价格和下单价格应一致"""
# 1. 记录原价
original_price = get_product_price(prod_id=75)
# 2. 买家加购
cart = BuyerCart(buyer_headers)
cart.add_to_cart(sku_id=402, prod_id=75, count=1)
# 3. 管理员改价
ProductApi(api_session).update_price(prod_id=75, new_price=99.99)
# 4. 买家查购物车,应显示新价(或提示"价格已变")
cart_info = cart.get_cart_info()
current_price = cart_info["data"][0]["shopCartItemDiscounts"][0]["shopCartItems"][0]["price"]
assert current_price == 99.99, "购物车未同步最新价格"
# 5. 恢复原价
ProductApi(api_session).update_price(prod_id=75, new_price=original_price)
场景 3:管理员禁用会员 → 买家 Token 该不该立刻失效?
python
def test_admin_disable_user_kills_token(api_session, buyer_headers):
"""管理员禁用会员后,买家的 Token 应该立刻失效"""
# 1. 买家先用 Token 能正常访问
resp1 = BuyerCart(buyer_headers).get_cart_info()
assert resp1["code"] == "00000"
# 2. 管理员禁用该会员
# user_id 为买家端用户的实际 ID,测试时从 buyer_headers 里解析出来
user_id = get_buyer_user_id(buyer_headers)
MemberApi(api_session).update_status(user_id=user_id, status=0)
# 3. 买家再用同一 Token 访问,应该被拒
resp2 = BuyerCart(buyer_headers).get_cart_info()
# 这里是关键:很多系统做不到"立刻失效"
assert resp2["code"] != "00000", "被禁用会员的 Token 仍然有效"
⚠️ 这个场景是双端系统经典坑点:禁用用户 ≠ 踢下线。很多系统仅修改数据库内用户状态标记,无法立即使已经下发的 Token 失效。想要实现 Token 即时失效,需要在 Redis 维护 Token 黑名单,或者使用用户状态版本号机制。本用例暂未在本地环境实测,仅作为测试思路。
场景 4:管理员改库存 → 买家端展示和下单校验是否一致?
同上"两层库存"的坑:展示读 tz_sku.actual_stocks,下单读 tz_prod.total_stocks。如果只改一张表,就会出现"显示有货但下单失败"或"显示缺货但实际能下单"。
结论:双端数据一致性是"两端联动测试"的核心------单独测哪端都测不出问题。
八、测试策略对比表
| 维度 | 管理员端 | 买家端 |
|---|---|---|
| 端口 | 8085 | 8086 |
| 前端 | Vue3 + Element Plus(9527) | Uniapp H5(80) |
| 登录加密 | AES + 时间戳 | AES + 时间戳(同一套) |
| 认证 | RBAC + 菜单 | Token + Redis |
| 核心模块 | 商品 / 订单 / 会员 / 门店 / 系统 | 浏览 / 加购 / 下单 / 订单查询 |
| 测试重点 | 权限、CRUD、数据一致性 | 用户旅程、并发、幂等、会话 |
| 典型用例 | 受限角色看不到菜单 | 重复提交订单只创建 1 个 |
| 接口风格 | /admin/xxx | /p/xxx |
结论:端口只是表象,权限模型是本质。两端测试策略必须跟着角色走。
九、怎么用一套代码覆盖双端
分目录 + 分角色 fixture + 共用 HTTP 客户端:
bash
api/
├── admin/ # 管理员端接口
│ ├── login_api.py
│ ├── product_api.py
│ └── ...
├── buyer/ # 买家端接口
│ ├── buyer_login.py
│ ├── buyer_product.py
│ ├── buyer_cart.py
│ └── ...
└── client.py # 共用 HTTP 客户端
fixture 分角色:
python
# ============ 买家端:session scope(Token 长效) ============
@pytest.fixture(scope="session")
def buyer_token():
return BuyerLogin().login()
@pytest.fixture(scope="session")
def buyer_headers(buyer_token):
return {"Authorization": buyer_token, "Content-Type": "application/json"}
# ============ 管理员端:function scope(Token 会互踢) ============
@pytest.fixture(scope="function")
def api_session(api_client):
token = LoginApi(api_client).admin_login(...)
api_client.set_token(token)
return api_client
⚠️ 关于 scope 的两点提醒
1. 买家端用 session 有前提条件
我本地这套配置下买家 Token 有效期较长(30 天),所以用 session。但要注意 Sa-Token 有 timeout 和 active-timeout 两套机制------如果开了"无操作过期",长时间不跑用例也会被踢。稳妥做法是在 fixture 里做"失效重登":
python
@pytest.fixture(scope="session")
def buyer_token():
token = BuyerLogin().login()
yield token
# 如果测试中检测到 401,重新登录
2. 管理员端用 function 也看配置
是否互踢取决于 is-concurrent、is-share 配置。我本地这套配置下,同账号重复登录会顶掉旧 Token,所以必须用 function。如果你改了并发登录配置(允许同账号多端登录),可以放宽到 session。
为什么这么分?
因为两端的"失败模式"不同:
| 失败模式 | 影响 |
|---|---|
| 买家端 Token 过期 | 只有这一条链路挂,重登就好 |
| 管理员端 Token 被顶 | 会连锁导致后续 N 条用例全部 401 |
混在一起维护,后期会崩。
十、我从这件事学到的
1. 双端测试不是"多做一遍"
同一个功能,两端设计目标不同------管理员端防"越权",买家端防"错乱"。测试策略必须跟着角色走。
2. 端口只是表象,权限模型是本质
表面上是"8085 vs 8086",本质是"RBAC vs Token"、"全局视野 vs 自己视野"。
3. 双端联动的坑,单独测哪端都测不出
管理员下架商品、禁用会员、改价格------这些操作的"效果",必须从买家端反向验证。
十一、系列文章
这是 Mall4j 自动化测试系列的第 3 篇:
- 第 1 篇:一个 Emoji 引发的排查:MySQL utf8mb3 导致接口返回异常MySQL)
- 第 2 篇:一个缺失的状态校验,让 Mall4j 的订单系统开了 4 个后门
- 第 3 篇:双端电商系统的测试差异分析(本文)
- 第 4 篇:JMeter 三档梯度压测,发现商品列表性能拐点
- 200 并发时 P99 响应时间从 26ms 暴涨到 677ms,涨了 26 倍,怎么定位的?
- 第 5 篇:从 Mall4j 的 3 个角色(买家 / 管理员 / 平台运营),聊聊真实项目需要几种角色
- 买家、管理员、平台运营,鉴权方式和测试策略完全不同。
免责声明:本文结论基于本地部署的某个 Mall4j 版本,不同版本可能已修复。如果你也遇到类似问题,建议先拉最新代码确认,再提 Issue。开源项目维护不易,理性反馈。
本项目为测试工程,不包含 Mall4j 源码。Mall4j 遵循 AGPL-3.0 协议,如需商用请联系原作者获取授权。
相关链接
你项目里有没有出现过这种坑:管理员刚下架了商品,买家还能下单成功?或者管理员禁用了账号,买家还能继续操作?评论区聊聊你是怎么防的 👇