双跑对比报告-认证与商品详情.md 6.2 KB

双跑对比报告 — 认证拦截器 + 商品详情

对比方式:同一请求分别发往 PHP 线上(https://shop.qinys.cn/index.php?s=)与 Java 本地(http://localhost:18080), 递归逐字段 diff JSON。脚本:tools/regress_goods.py。

一、认证拦截器(Access-Token)

PHP 端实现(已核对源码)

环节 实现位置 规则
token 来源 app/common/service/store/User::getToken() 请求头 Access-Token;仅 is_debug 时额外接受请求参数
token 生成 User::makeToken() md5("{microtime}_{userId}_{guid}_store_user_salt_")
登录态存储 User::login() → Cache::set($token, ['user'=>..,'store_id'=>..,'is_login'=>true], 7天) ThinkPHP File 缓存(config/cache.php 的 CACHE.DRIVER=file),非 Redis
校验 app/store/controller/Controller::checkLogin() 白名单 passport/login、passport/logout、map/transfer;否则登录态为空或 is_login != 1 → 抛 401
错误响应 cores/ExceptionHandle::output() {"status":401,"message":"请先登录后再访问","data":{"isPrompt":true}},HTTP 恒 200
商城 ID app/common.php::getStoreId() 单商户版硬编码 10001(不来自 token)

File 缓存文件格式(Java 端已完整复现)

文件名:{runtime}/cache/{md5(token)[0:2]}/{md5(token)[2:]}.php
内容  :"<?php\n//" + %012d(过期绝对时间戳) + "\n exit();?>\n" + PHP serialize(缓存值)
        头部固定 32 字节;过期判断 time() - expire > filemtime

PHP serialize 解析器为本项目自研(PhpUnserializer),按字节解析字符串长度, 中文昵称(UTF-8 3 字节/字)不会错位。

Java 端实现

类 职责
common/auth/StoreAuthInterceptor 白名单、取 token、写 401、注入 loginInfo
common/auth/ThinkFileCacheTokenStore 按 ThinkPHP 规则定位并读取缓存文件
common/auth/PhpUnserializer PHP serialize 解析(a/s/i/d/b/N)
common/auth/LoginInfo user / store_id / is_login

配置:

yoshop:
  auth:
    mode: "off"              # off=开发/双跑放行;file=真实校验
    runtime-cache-path: /www/wwwroot/shop/runtime/cache   # PHP 部署机缓存目录

⚠️ mode: off 必须加引号 —— YAML 1.1 会把裸 off 解析成布尔 false,导致拦截器误判为开启。 这是实测踩到的坑,已写进配置注释。

实测结果(4 场景)

场景 PHP 线上 Java 本地
无 token 401 请先登录后再访问 ✅ 一致
假 token 401 请先登录后再访问 ✅ 一致
有效 token(合成缓存文件) 200 ✅ 200,解析出 storeId=10001、is_login=true、中文昵称正确
白名单 passport/login 放行 ✅ 放行

已知限制

  1. PHP 生产用 file 缓存,目录在云服务器 47.121.25.205 上,本地 Java 读不到。 mode: file 只在 Java 与 PHP 同机(或挂载同一 runtime 目录)时才可用;纯本地双跑期用 off。
  2. checkPrivilege(API 级权限)尚未实现。PHP 会按登录用户的角色查 store_api 表校验, Java 端目前只校验登录态。上线前必须补齐,否则低权限账号在 Java 端能访问超出其权限的接口。
  3. is_debug 下允许用请求参数传 token 的兜底逻辑未复现(生产为 APP_DEBUG=false,无影响)。

二、商品详情 /store/goods/detail

PHP 组装链

getDetail(goodsId)
├─ getBasic: detail(goodsId, with['images.file','skuList.image','video','videoCover'])
│            └─ setGoodsData: goods_images = images[].file;goods_image = 第一张 preview_url
├─ categoryIds  → yoshop_goods_category_rel
├─ specList     → 仅 spec_type=20(多规格)
├─ serviceIds   → yoshop_goods_service_rel
└─ isSpecLocked → Store\Service\Goods::checkSpecLocked()(当前源码固定返回 false)

挖出的隐性约定(均已对齐)

现象 PHP 行为 Java 处理
content 库内 HTML 实体编码,出参 htmlspecialchars_decode PhpStr.htmlspecialcharsDecode,单遍替换
delivery_type 库内字符串 '[10]',出参数组 [10] JacksonTypeHandler 反序列化为 List
alone_grade_equity 库内 '[]',出参 [] 同上
points_discount_config 库内空串,出参 ''(未做 json 转换) 保持 String
sku goods_props / spec_value_ids 库内空串,经 json 转换出参 null 空串转 null
sku goods_weight double 列,1.0 出参为 1(PHP 不保留零小数) PhpFloatSerializer
详情专用键 skuList/video/videoCover/categoryIds/serviceIds/isSpecLocked/specList 只在详情出现 @JsonView(Views.Detail.class) + 列表用 Views.List
视频/封面 video_id=0 时出参 null(键仍在) 查 0 跳过,输出 null

@JsonView 需要 spring.jackson.mapper.default-view-inclusion: true, 否则未标注视图的字段会被全部排除,接口直接返回 {}(实测踩坑)。

分页越界行为(回归时发现)

PHP think-orm\Paginator::setCurrentPage:请求页码 > 总页数时,current_page 被夹到 last_page (last_page=0 时为 1),但 data 仍为空数组。Java PageVO.of 已按此实现。

测试结果

PASS list  12 组参数(翻页/搜索/状态/分类/销量排序/价格升降序/listType 三种)
PASS detail goodsId=10010(本店测试商品)
PASS detail goodsId=10003(单规格、goods_weight 为整数)
PASS detail goodsId=999999(商品不存在:500 + "很抱歉,商品信息不存在" + data.isPrompt=true)
总差异: 0

三、复跑方式

# 1. 启动 Java(8080 被占用,统一用 18080)
cd yoshop-java
mvn -q -DskipTests spring-boot:run -Dspring-boot.run.arguments=--server.port=18080

# 2. 全参数回归(自动拉 PHP 线上对比)
python -X utf8 ../tools/regress_goods.py

# 3. 认证拦截器验证(需合成缓存文件,见 tools 说明)
mvn -q -DskipTests spring-boot:run -Dspring-boot.run.arguments="--server.port=18080 --yoshop.auth.mode=file --yoshop.auth.runtime-cache-path=<缓存目录>"