# 双跑对比报告 — 认证拦截器 + 商品详情 > 对比方式:同一请求分别发往 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 内容 :"\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 | 配置: ```yaml 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 ``` ## 三、复跑方式 ```bash # 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=<缓存目录>" ```