对比方式:同一请求分别发往 PHP 线上(
https://shop.qinys.cn/index.php?s=)与 Java 本地(http://localhost:18080), 递归逐字段 diff JSON。脚本:tools/regress_goods.py。
| 环节 | 实现位置 | 规则 |
|---|---|---|
| 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) |
文件名:{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 字节/字)不会错位。
| 类 | 职责 |
|---|---|
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,导致拦截器误判为开启。 这是实测踩到的坑,已写进配置注释。
| 场景 | PHP 线上 | Java 本地 |
|---|---|---|
| 无 token | 401 请先登录后再访问 |
✅ 一致 |
| 假 token | 401 请先登录后再访问 |
✅ 一致 |
| 有效 token(合成缓存文件) | 200 | ✅ 200,解析出 storeId=10001、is_login=true、中文昵称正确 |
白名单 passport/login |
放行 | ✅ 放行 |
mode: file 只在 Java 与 PHP 同机(或挂载同一 runtime 目录)时才可用;纯本地双跑期用 off。checkPrivilege(API 级权限)尚未实现。PHP 会按登录用户的角色查 store_api 表校验,
Java 端目前只校验登录态。上线前必须补齐,否则低权限账号在 Java 端能访问超出其权限的接口。is_debug 下允许用请求参数传 token 的兜底逻辑未复现(生产为 APP_DEBUG=false,无影响)。/store/goods/detailgetDetail(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=<缓存目录>"