本文整理两条容易混淆的链路:一条是“蓄电池专卖服务站只能经营哪些商品”,另一条是“机器人如何按电池型号、容量等参数查询商品”。前者是经营权限,后者是查询场景,二者不能互相替代。

1. 业务目标

  1. 标记哪些服务站属于蓄电池专卖服务站。
  2. 按分类和品牌配置专卖站可经营的快准自营电池。
  3. 将该服务站自己的第三方商品并入可售范围。
  4. 缓存可售 invId 和转换后的 SKU,降低多入口重复查询成本。
  5. 在商品列表、采购、销售、销退、采购售后、预售、导入和库存查询中统一拦截越权商品。
  6. 为电池专卖站隐藏不适用的菜单和普通经营入口。
  7. 支持无 VIN 电池参数识别和商品搜索。
  8. 在 Robot V1/V2 中按型号、类型、外壳、电极、容量、OE、CCA 查询和报价。

2. 核心结论

结论说明
电池站身份来自主账号t_sys_admin 中站点主账号 roleid=0 且 isBattery=1
自营范围是分类+品牌白名单t_bs_battery_category_brand 关联快准基础商品
第三方范围不是分类品牌白名单代码直接纳入 BS_GOODS.sid=当前站点 的全部商品
可售范围是 invId 集合自营与第三方合并后缓存 1 小时
空集合当前不会写缓存会导致每次请求重新查库,并可能在部分调用方形成“未限制”风险
页面过滤不是最终安全边界采购、销售和退货提交时还会再次校验
电池查询不等于电池站权限Robot 的 isBattery 表示本次查询场景,不是站点身份
机器人查询依赖销售服务OfferProvider::searchBatteryGoods() 返回候选后再走 DGJ 商品处理、价格和库存

3. 两条业务链

flowchart LR
  subgraph Permission["经营权限链"]
    A["主账号 isBattery"] --> B["分类品牌配置"]
    B --> C["生成可售 invId"]
    C --> D["Redis 1小时缓存"]
    D --> E["采购/销售/退货/商品列表拦截"]
  end
  subgraph Inquiry["电池询价链"]
    F["图片/文本/AI回调"] --> G["提取电池参数"]
    G --> H["OfferProvider 搜索"]
    H --> I["商品/库存/价格处理"]
    I --> J["机器人返回商品"]
  end

两个链路可能在商品处理阶段交汇,但代码中的“电池查询场景”不能证明当前站点是专卖站,反之亦然。

4. 代码地图

4.1 经营权限

路径责任
application/Services/BatteryUser/BatteryUserService.php身份判断、范围生成、Redis 缓存、差异校验和错误信息
application/models/bs/BatteryModel.php分类品牌配置关联快准商品
application/models/bs/AdminModel.php查询电池站主账号
application/KzData/Enums/AdminEnums.phpBATTERY_YES/BATTERY_NO
application/KzData/Enums/KzEnums.php全部/自营/第三方范围类型
application/KzData/Enums/BatteryEnums.php采购/销售错误码
application/KzData/Enums/RedisKeys.php可售 invId 缓存 key
application/config/menus_config.php电池站菜单显示差异

4.2 业务拦截

路径场景
application/Services/PoOrders/PoMaterielSer.php采购商品查询和供给库存
application/controllers/scm/InvPo.php采购数量限制提交前检查
application/service/scm/InvSaService.php销售开单保存校验
application/Services/InvSa/NormalSaleReturnSer.php普通销售退货
application/controllers/po/AfterSale.php采购售后/退货
application/service/scm/InvPreService.php预售/活动商品
application/service/bs/InventoryService.phpMongo/ES 商品列表和联想
application/Services/Materiels/MaterielFrontCacheSer.php前端商品缓存过滤
application/controllers/basedata/Import.php商品/业务导入过滤
application/Services/Import/Type/SaleOutGoodsService.php销售出库导入
application/Services/ExclusiveRebate/ExclusiveRebateSer.php专属返利商品范围

4.3 机器人查询

路径责任
application/Services/MoveMall/RobotInquiryInput.phpV1 AI 回调与开关判断
application/Services/MoveMall/RobotInquirySer.phpV1 电池参数搜索和结果处理
application/Services/MoveMall/RobotV2/Adapters/Input/TencentImAdapter.phpV2 AI 回调标准化
application/Services/MoveMall/RobotV2/Services/Query/BatteryQueryService.phpV2 参数构建和 Provider 查询
application/Services/MoveMall/RobotV2/Services/Query/QueryDispatcher.php按电池场景分派
application/Services/MoveMall/RobotV2/Strategies/BatteryStrategy.php兼容旧查询策略
application/Providers/SaleService/OfferProvider.php调用销售服务电池搜索接口

5. 数据模型

5.1 核心表和缓存

类型名称用途关键字段
主账号t_sys_admin电池站身份sid,roleid,isBattery,isDelete
范围配置t_bs_battery_category_brand站点允许的分类品牌sid,categoryId,brandId,isDelete
商品t_bs_goods自营/第三方商品id,sid,categoryId,brandId,skuId,isDelete
商品扩展t_bs_goods_ext销售价等扩展sid,invId,retailPrice
Redisdgj:battery_invids_sid_list:<sid><type>可售 invId JSON 数组TTL 3600 秒

真实前缀由 RedisKeys::PREFIX 决定,排查时从环境配置确认,不要只按文档猜 key。

5.2 关系图

erDiagram
  SYS_ADMIN ||--o| BATTERY_STATION : identifies
  BATTERY_STATION ||--o{ BATTERY_CATEGORY_BRAND : configures
  BATTERY_CATEGORY_BRAND }o--o{ KZ_GOODS : matches_category_brand
  BATTERY_STATION ||--o{ STATION_GOODS : owns_third_party
  KZ_GOODS }o--o{ BATTERY_ALLOWED_IDS : contributes
  STATION_GOODS }o--o{ BATTERY_ALLOWED_IDS : contributes
  BATTERY_ALLOWED_IDS ||--|| REDIS_CACHE : cached_as_json

BATTERY_STATION 和 BATTERY_ALLOWED_IDS 是逻辑概念,不是独立物理表。

6. 电池站身份

6.1 判断条件

BatteryUserService::isBatteryStation($sid) 调用 AdminModel::getBatteryBySid(),条件是:

sid = 目标站点
roleid = 0
isDelete = 0
isBattery = 1
limit 1
flowchart TD
  A["输入 sid"] --> B["查 t_sys_admin"]
  B --> C{"存在未删除的 roleid=0 主账号且 isBattery=1"}
  C -->|是| D["电池专卖服务站"]
  C -->|否| E["普通服务站"]

6.2 身份风险

风险原因
主账号重复若同站有多条 roleid=0,limit 1 结果依赖数据一致性
主账号软删新旧主账号切换时 isBattery 可能未同步
无身份缓存每个入口都可能查主账号表,热点请求有额外 DB 压力
菜单与接口时点不同登录会话菜单已生成后再改身份,页面可能需重新登录

7. 可售范围类型

枚举:KzEnums。

类型值范围
BATTERY_ALL0自营白名单 + 当前站第三方商品
BATTERY_KZ1只取快准自营白名单
BATTERY_THIRD2只取当前站第三方商品
flowchart TD
  A["getIdsBySid(sid,type)"] --> B{"type"}
  B -->|1 自营| C["分类品牌配置 JOIN 快准商品"]
  B -->|2 第三方| D["查询 BS_GOODS.sid=当前站"]
  B -->|0 全部| E["array_merge 自营 + 第三方"]
  C --> F["invId 数组"]
  D --> F
  E --> F

当前合并使用 array_merge,没有显式 array_unique。如果同一 ID 因历史数据异常重复,缓存可包含重复项,通常不影响 in_array/array_diff 语义,但会增加 key 大小和 SQL IN 长度。

8. 自营可售范围

8.1 查询语义

BatteryModel::getIdsBySid():

SELECT b.id
FROM t_bs_battery_category_brand a
LEFT JOIN t_bs_goods b ON b.categoryId = a.categoryId
WHERE a.sid = :sid
  AND a.brandId = b.brandId
  AND b.sid = 1
  AND b.isDelete = 0
  AND a.isDelete = 0;

其中 b.sid=KzEnums::KZ_SID 表示快准基础商品。

8.2 匹配逻辑

flowchart LR
  A["站点配置 categoryId + brandId"] --> B["快准 BS_GOODS"]
  B --> C{"分类和品牌同时相等"}
  C -->|是| D["加入自营可售 invId"]
  C -->|否| E["排除"]

可售范围按商品当前分类和品牌动态展开。商品中心修改分类/品牌后,即使配置表不变,下一次缓存重建结果也会变化。

8.3 不在这里判断的条件

该查询只看分类、品牌、商品未删除,不看:

  • 商品供应状态。
  • 当前库存是否大于 0。
  • 采购价格是否存在。
  • 区域/站点是否可供。
  • 活动、限购或价格限制。

这些条件由后续商品、采购或销售 Service 继续判断。可售白名单只是第一层。

9. 第三方可售范围

第三方闭包查询:

BS_GOODS where sid = 当前站点,返回全部 id

代码未在该层显式增加 isDelete=0。是否由 GoodsModel::getList() 默认过滤,需要结合 Model 实现确认;生产排查不能默认已过滤。

9.1 业务含义

电池站自己的第三方商品全部进入范围,不受 t_bs_battery_category_brand 约束。这意味着配置表只治理快准自营商品,并不是整个站点商品的分类品牌白名单。

10. Redis 缓存

10.1 Key

RedisKeys::BATTERY_INVIDS_SID_LIST . sid . type

注意 sid 和 type 直接拼接,中间没有显式分隔符。若 sid/type 位数或类型扩展,存在 key 可读性和碰撞设计风险;当前 type 仅 0/1/2 时一般可区分。

10.2 读写流程

sequenceDiagram
  participant Caller as 业务入口
  participant Battery as BatteryUserService
  participant Redis as Redis
  participant DB as 配置/商品表
  Caller->>Battery: getInvIdsCacheBySid(sid,type,useCache)
  alt useCache=true
    Battery->>Redis: GET key
    alt 命中非空字符串
      Redis-->>Battery: JSON invIds
      Battery-->>Caller: json_decode 结果
    else 未命中
      Battery->>DB: getIdsBySid
      DB-->>Battery: invIds
      Battery->>Redis: 非空才 SET TTL=3600
      Battery-->>Caller: invIds
    end
  else useCache=false
    Battery->>DB: 强制重建
    Battery->>Redis: 非空才覆盖
    Battery-->>Caller: invIds
  end

10.3 空集合问题

当数据库计算结果为空时,代码不写缓存,也不删除旧缓存:

  • 若旧 key 仍存在且 useCache=true,会继续返回旧范围直到 TTL。
  • 若 key 不存在,每次请求都会查数据库。
  • 某些调用方只有在 $batteryIds 非空时才附加过滤,空数组可能被解释为“不过滤”,而业务上可能应是“一个商品都不允许”。

空集合语义必须统一:电池站无配置时到底是禁止全部、允许全部还是配置异常。当前不同调用方式可能产生不同结果。

11. 缓存一致性

stateDiagram-v2
  [*] --> Missing
  Missing --> Valid: 首次计算非空并写入
  Valid --> Stale: 身份/配置/商品变化
  Stale --> Valid: TTL 到期后重建
  Stale --> Valid: useCache=false 强制重建
  Valid --> EmptyConfig: 配置被清空
  EmptyConfig --> Stale: 旧 key 仍在 TTL 内
  EmptyConfig --> Missing: key 到期且空值不缓存

建议配置写入后主动删除三个 type key,而不是等待 1 小时;空集合也应缓存短 TTL 的 [],并由调用方明确执行“禁止全部”或指定业务语义。

12. 可售 SKU

getSkuIdsBySid() 先获取可售 invId,再调用商品 Model 转为 skuId。

flowchart LR
  A["sid + type"] --> B["Redis/DB invIds"]
  B --> C["getGoodsBaseCollectionByInvIds"]
  C --> D["array_column skuId"]

SKU 用于调用商品中心或供给服务;invId 用于 DGJ 本地业务。排查时必须区分两者,不能把 SKU 当表主键。

13. 通用校验方法

13.1 checkDiffInvIds

异常商品 = 请求 invIds - 电池站全部可售 invIds

它不先判断站点身份,调用方应先调用 isBatteryStation()。

13.2 checkGoodsMessage

对异常 invId 回查 skuId、productCode,并通过其在原始数组的位置返回 1 起始行号。

返回示意:

[
  {
    "row": 2,
    "skuId": "SKU-EXAMPLE",
    "productCode": "PRODUCT-EXAMPLE"
  }
]

若原始数组含重复 invId,array_search 只返回第一次位置。

13.3 valideGoods

flowchart TD
  A["sid + invIds"] --> B{"是否电池站"}
  B -->|否| C["直接通过"]
  B -->|是| D["计算差集"]
  D -->|空| C
  D -->|非空| E["取第一个异常商品"]
  E --> F["抛出 第N行+SKU+业务消息"]

该方法只在错误文字中使用第一条异常,但完整错误列表可由 checkGoodsMessage 获取。

14. 商品列表过滤

14.1 Mongo 商品列表

InventoryService::getGoodsListNew() 根据查询类型决定范围:

页面类型电池 type商品范围
all0自营 + 第三方
kz1只限自营白名单
第三方分支2当前站商品

如果页面已传 ids,则与电池可售 IDs 取交集;未传时直接用可售 IDs 作为查询条件。

flowchart TD
  A["Mongo 商品查询"] --> B{"是否电池站"}
  B -->|否| C["按普通条件查询"]
  B -->|是| D["按页面 type 取可售 IDs"]
  D --> E{"请求是否已有 IDs"}
  E -->|是| F["intersection"]
  E -->|否| G["使用全部可售 IDs"]
  F -->|空| H["立即返回空分页"]
  F --> I["Mongo id IN"]
  G --> I

14.2 ES 联想

快准商品联想只使用 BATTERY_KZ,将可售 IDs 放入 ES terms id。因此 Mongo、ES 和 DB 的 ID 类型(字符串/整数)必须兼容。

14.3 前端缓存

MaterielFrontCacheSer 对电池站也会限制商品,若前端缓存构建早于范围变化,可能出现列表与提交校验不一致。

15. 采购商品查询

PoMaterielSer::queryPoMaterielByInvIds():

sequenceDiagram
  participant UI as 采购页面
  participant Po as PoMaterielSer
  participant Battery as BatteryUserService
  participant Cache as MaterielCacheStation
  participant Item as 商品中心
  UI->>Po: sid + invIds
  Po->>Battery: isBatteryStation
  alt 电池站
    Po->>Battery: getInvIdsCacheBySid(type=ALL)
    Po->>Po: 请求 IDs 与可售 IDs 取交集
    alt 交集为空
      Po-->>UI: 未查询物料信息
    end
  end
  Po->>Cache: 查询本地采购物料
  Po->>Item: 查询可采购商品和供给
  Po-->>UI: 价格、库存、箱规等

这里只保留交集,若请求同时包含可售和不可售商品,可能只返回可售部分,而不是明确指出哪些被过滤。提交前 quantityLimitCheck 会返回具体错误行。

16. 采购提交前校验

接口方法:scm/InvPo::quantityLimitCheck()。

POST /scm/invpo/quantitylimitcheck
Content-Type: application/x-www-form-urlencoded

JXCSID=10001&goods=[{"invId":30001,"num":2}]

16.1 错误响应语义

{
  "status": "error",
  "message": "物料不允许采购",
  "data": {
    "errorData": [
      {"row": 1, "skuId": "SKU-EXAMPLE", "productCode": "PRODUCT-EXAMPLE"}
    ],
    "errorCode": "battery_po_error"
  }
}

具体响应外壳由 splashJson 决定,示例重点是 errorData 和 battery_po_error。

17. 特殊采购限制

InvPoService 还有“订单中存在特定蓄电池,不能走普通采购,需到快准营销活动模块下单”的校验。这属于商品/活动级采购通道限制,不等同于电池站可售范围。

flowchart TD
  A["采购商品已通过电池站范围"] --> B{"是否属于指定活动电池"}
  B -->|是| C["拒绝普通采购,指引活动采购"]
  B -->|否| D["继续采购订单校验"]

排查“明明在白名单仍不能采购”时要继续看活动通道限制。

18. 销售开单校验

InvSaService 在保存链路再次执行:

若为电池站:
  batteryInvIds = 可售范围
  diffIds = 请求销售 invIds - batteryInvIds
  diffIds 非空 -> 返回/抛出电池销售错误
sequenceDiagram
  participant UI as 销售开单
  participant Sa as InvSaService
  participant Battery as BatteryUserService
  participant Order as 销售订单
  UI->>Sa: 提交商品明细
  Sa->>Battery: 判断电池站并取可售 IDs
  Sa->>Sa: array_diff
  alt 有越权商品
    Sa-->>UI: BATTERY_SA_ERROR / 商品行信息
  else 全部允许
    Sa->>Order: 继续价格、库存、客户、单据校验
  end

前端商品列表与销售提交之间存在时间窗口:缓存或配置变化后,提交校验可能拒绝此前已选商品,这是正确的后端兜底,但页面应明确刷新和错误行。

19. 销售退货与采购售后

场景入口行为
普通销退NormalSaleReturnSer电池站退货商品必须仍在允许范围
采购售后controllers/po/AfterSale.php调用 valideGoods() 拦截不允许退货商品

这里存在业务问题:历史成交时允许、后来配置移除的商品,是否还应允许退货?当前校验使用“现在的可售范围”,可能阻止合法历史退货。生产口径需确认是否应按原单来源放行。

flowchart TD
  A["退货商品"] --> B{"当前仍在电池可售范围"}
  B -->|是| C["继续原单/数量/库存校验"]
  B -->|否| D{"业务是否应按历史原单放行"}
  D -->|当前代码通常否| E["拒绝退货"]
  D -->|建议规则| F["验证原单当时合法后放行"]

20. 预售与活动

InvPreService 对电池站会取可售 IDs,并用于活动计划商品查询。含义是活动商品基础范围也不能绕过专卖站经营范围。

ExclusiveRebateSer 根据自营/第三方类型取不同电池范围,再与专属返利商品相交。

flowchart LR
  A["活动/返利候选商品"] --> B["活动自身范围"]
  C["电池站可售范围"] --> D["intersection"]
  B --> D
  D --> E["最终可参与商品"]

21. 导入链路

导入必须处理两类问题:

  1. 模板中的商品是否存在。
  2. 商品存在但是否属于电池站可经营范围。

多个导入入口会先获取电池 IDs,再逐行排除不允许商品。排查时要区分“导入解析失败”和“经营范围校验失败”。错误文件应保留行号、SKU、产品码和原因,不应只返回空结果。

22. 菜单影响

menus_config.php 中:

$battery_black_list = isBatteryStation($sid) ? false : true;

变量名表示“电池黑名单控制”,实际值可理解为“非电池站是否显示”。例如首配采购菜单 display=$battery_black_list:

站点battery_black_list首配采购显示
电池站false隐藏
普通站true显示
flowchart TD
  A["登录生成菜单"] --> B{"isBatteryStation"}
  B -->|是| C["battery_black_list=false"]
  B -->|否| D["battery_black_list=true"]
  C --> E["隐藏首配等不适用入口"]
  D --> F["显示普通入口"]

菜单隐藏不是权限安全,后端接口仍需校验站点身份和商品范围。

23. 电池机器人查询输入

23.1 支持字段

字段含义处理
productModel产品型号原样传给销售服务
nsModel国家标准/电池型号原样传递
batteryType启停、免维护等类型原样传递
shellModel外壳型号原样传递
electrode电极/极性原样传递
ampereHour容量 Ah只保留数字
oeCodeOE 编码原样传递
cca冷启动电流原样传递
brandIds品牌过滤逗号拼接为 brand_id

23.2 分类

Robot 常量中:

分类编码名称
C20101启停蓄电池
C20102免维护蓄电池
C20103辅助电池

旧无车关键字映射明确包含前两类;辅助电池在不同枚举列表中存在,但完整无车查询支持范围需实测。

24. AI 解析开关

V1 读取客户配置:

字段作用
enable_ai_parseAI 解析总开关
enable_ai_parse_battery电池细分开关
enable_ai_parse_oil油品开关
enable_ai_parse_tire轮胎开关
flowchart TD
  A["收到 AI 异步回调"] --> B{"enable_ai_parse=1"}
  B -->|否| X["忽略解析结果"]
  B -->|是| C{"productType=电池"}
  C -->|否| D["走油品/轮胎/其他分支"]
  C -->|是| E{"enable_ai_parse_battery=1"}
  E -->|否| X
  E -->|是| F["保存完整 aiParseResult"]
  F --> G["进入无 VIN 电池查询"]

“客户”在这里是修理厂联系人配置,不是电池专卖服务站身份。

25. V2 AI 回调

腾讯 IM ai_callback 示例结构:

{
  "msgType": "ai_callback",
  "msgData": {
    "ai_result": {
      "isPart": true,
      "productType": 2,
      "data": {
        "nsModel": "6-QW-60",
        "batteryType": "免维护",
        "shellModel": "L2",
        "electrode": "正装",
        "ampereHour": "60Ah",
        "oeCode": "",
        "cca": ""
      }
    },
    "pre_conversation_id": 80001
  }
}

25.1 适配流程

sequenceDiagram
  participant AI as AI 识别
  participant Adapter as TencentImAdapter
  participant Metadata as MessageContext
  participant State as ProductQueryingState
  AI->>Adapter: ai_callback
  Adapter->>Adapter: normalize productType=2 -> battery
  Adapter->>Metadata: INTENT_AI_PARSE_RESULT
  Adapter->>Metadata: INTENT_AI_QUERY_TYPE=battery
  Adapter->>Metadata: INTENT_BATTERY_PARAMS 全字段
  Metadata->>State: 构建 QueryContext
  State->>State: hints.isBattery=true

电池与油品/轮胎不同:它不把某一个规格简单改写为普通文本,而是保留完整结构化参数。

26. V2 电池查询

BatteryQueryService::buildParams() 构造:

{
  "sid": 10001,
  "customer_id": 20001,
  "page": 1,
  "limit": 20,
  "productModel": "",
  "nsModel": "6-QW-60",
  "batteryType": "免维护",
  "shellModel": "L2",
  "electrode": "正装",
  "ampereHour": "60",
  "oeCode": "",
  "cca": "",
  "brand_id": "601,602"
}

26.1 调用链

sequenceDiagram
  participant State as ProductQueryingState
  participant Dispatcher as QueryDispatcher
  participant Battery as BatteryQueryService
  participant Provider as OfferProvider
  participant Sale as 销售服务
  State->>Dispatcher: hints.isBattery=true
  Dispatcher->>Battery: query(QueryContext)
  Battery->>Battery: buildParams + Ah 只保留数字
  Battery->>Provider: searchBatteryGoods
  Provider->>Sale: HTTP 请求
  Sale-->>Provider: rows/product_codes
  Provider-->>Battery: 搜索结果
  Battery->>Battery: 给商品附加 battery_info
  Battery-->>Dispatcher: products

limit 默认 20。是否还有服务端最大值和分页能力需要结合销售服务契约确认。

27. V1 电池查询

V1 RobotInquirySer::searchBatteryGoods():

  1. 从 AI 解析结果提取电池参数。
  2. 所有核心参数都为空时直接认为无法搜索。
  3. 根据品牌名称查品牌编码并拼入参数。
  4. 调用同一个 OfferProvider::searchBatteryGoods()。
  5. 将返回商品交给 MoveMallGoodsSer::moveGoodsHandler() 补价格、库存和可售信息。
  6. 补商品简称、标签和 battery_info。
  7. 先按分类品牌排序配置,再按同品牌年销量降序。
flowchart TD
  A["AI 电池参数"] --> B{"至少一个参数非空"}
  B -->|否| X["无法搜索"]
  B -->|是| C["解析品牌编码"]
  C --> D["OfferProvider 搜索"]
  D --> E{"rows 是否为空"}
  E -->|是| F["返回 product_codes + 空商品"]
  E -->|否| G["MoveMallGoodsSer 处理价格库存"]
  G --> H["补简称和 battery_info"]
  H --> I["品牌排序 + 年销量排序"]
  I --> J["机器人展示"]

28. 电池查询结果与经营范围

销售服务搜索结果进入 MoveMallGoodsSer 后还会走站点、客户、价格、库存等过滤。是否明确复用 BatteryUserService 的经营范围,应沿真实 moveGoodsHandler 路径确认。

必须分别验证:

条件预期
普通站发起电池查询可按普通站商品规则查询,不因 isBatteryStation=false 禁止查询
电池站查询白名单商品正常返回并报价
电池站查询非白名单自营商品应被经营范围过滤
电池站查询自己的第三方商品按第三方规则可返回
AI 查询到商品但无价格可展示无价/转人工,不能伪造价格

29. 状态和错误码

29.1 身份状态

值含义
AdminEnums::BATTERY_NO=0普通站
AdminEnums::BATTERY_YES=1蓄电池专卖站

29.2 错误码

错误码场景
battery_po_error采购商品不在允许范围
battery_sa_error销售商品不在允许范围

29.3 Robot 会话

值含义
CONVERSATION_NO_VIN_BATTERY=4无 VIN 电池会话
CONVERSATION_PARSE_PRODUCT_TYPE_BATTERY=2AI 产品类型为电池

30. 配置变更流程

仓库中未发现独立、明确的电池范围管理 Controller,需要继续确认配置由 OPS、SQL、外部同步还是隐藏页面维护。无论入口在哪,正确流程应是:

flowchart TD
  A["修改站点 isBattery 或分类品牌"] --> B["事务保存配置"]
  B --> C["删除 sid+0/1/2 三个 Redis key"]
  C --> D["重建并统计新范围"]
  D --> E["与旧范围做新增/移除 diff"]
  E --> F["验证 Mongo/ES/采购/销售"]
  F --> G["通知站点重新登录刷新菜单"]

配置变更应输出被新增和移除的 SKU 清单,避免批量分类品牌变化无感扩大经营范围。

31. 完整排查 SOP

用户报告“这个电池为什么不能采购/销售”时:

flowchart TD
  A["确认 sid + invId/SKU + 场景"] --> B{"是否电池站"}
  B -->|否| C["转普通商品/供给/价格排查"]
  B -->|是| D{"商品是快准自营还是本站第三方"}
  D -->|自营| E["查商品 categoryId+brandId"]
  E --> F["查站点分类品牌配置"]
  D -->|第三方| G["查 BS_GOODS.sid 和删除状态"]
  F --> H["计算 DB 可售集合"]
  G --> H
  H --> I["对比 Redis type=0/1/2"]
  I --> J{"业务入口是否使用正确 type"}
  J -->|否| K["修调用参数"]
  J -->|是| L{"是否还有活动/供给/库存/价格限制"}
  L -->|是| M["进入对应业务专题"]
  L -->|否| N["检查前端/ES/Mongo缓存"]

32. 常见问题

32.1 配置了分类品牌仍不可售

检查顺序:

  1. 站点主账号是否确实 isBattery=1。
  2. 配置 sid/categoryId/brandId/isDelete。
  3. 商品是否是快准 sid=1,分类品牌是否完全相等。
  4. 商品是否已删除。
  5. Redis 自营 key 是否仍是旧值。
  6. 商品供给、库存、价格和活动通道限制。

32.2 删除配置后仍能看到

可能原因:旧 Redis key 最长保留 1 小时;前端/Mongo/ES 还有二级缓存;空结果不覆盖旧缓存。

32.3 商品列表看不到但提交报错行不同

列表可能已按交集静默过滤,提交按原始顺序返回差异;同时 checkGoodsMessage 对重复 ID 只返回第一次行号。

32.4 电池查询有结果但下单不允许

查询场景只负责找到候选,最终还要过电池站经营范围、商品供给、价格、库存和销售限制。

32.5 历史商品无法退货

当前可售范围已经移除该商品,而退货代码按当前范围校验。需要业务确认是否按原单合法性放行,不能直接加回白名单掩盖问题。

33. 只读 SQL

33.1 身份

SELECT uid, sid, roleid, isBattery, isDelete, status
FROM t_sys_admin
WHERE sid = :sid
  AND roleid = 0
ORDER BY uid;

33.2 范围配置

SELECT id, sid, categoryId, brandId, isDelete
FROM t_bs_battery_category_brand
WHERE sid = :sid
ORDER BY categoryId, brandId;

33.3 自营展开

SELECT c.id AS config_id,
       c.categoryId,
       c.brandId,
       g.id AS inv_id,
       g.skuId,
       g.productCode,
       g.name
FROM t_bs_battery_category_brand c
JOIN t_bs_goods g
  ON g.categoryId = c.categoryId
 AND g.brandId = c.brandId
WHERE c.sid = :sid
  AND c.isDelete = 0
  AND g.sid = 1
  AND g.isDelete = 0
ORDER BY c.categoryId, c.brandId, g.id;

33.4 第三方商品

SELECT id AS inv_id, skuId, productCode, categoryId, brandId, isDelete
FROM t_bs_goods
WHERE sid = :sid
ORDER BY id;

33.5 检查配置无商品

SELECT c.id, c.categoryId, c.brandId
FROM t_bs_battery_category_brand c
LEFT JOIN t_bs_goods g
  ON g.sid = 1
 AND g.categoryId = c.categoryId
 AND g.brandId = c.brandId
 AND g.isDelete = 0
WHERE c.sid = :sid
  AND c.isDelete = 0
GROUP BY c.id, c.categoryId, c.brandId
HAVING COUNT(g.id) = 0;

34. Redis 排查

先从配置读取真实前缀和 Redis DB,再执行只读命令:

redis-cli GET '<prefix>battery_invids_sid_list:<sid>0'
redis-cli TTL '<prefix>battery_invids_sid_list:<sid>0'
redis-cli GET '<prefix>battery_invids_sid_list:<sid>1'
redis-cli GET '<prefix>battery_invids_sid_list:<sid>2'

不要在未确认环境时直接 DEL。生产刷新应通过受控脚本记录 sid、旧值摘要、新值数量和操作者。

34.1 集合对账

ALL 集合 ≈ KZ 集合 ∪ THIRD 集合
请求异常集合 = requestInvIds - ALL 集合
自营 DB 集合 = 配置分类品牌 JOIN 快准商品

由于当前代码未显式去重,数量对账应同时比较 count 和 count(unique)。

35. 代码检索

rg -n "isBatteryStation|getInvIdsCacheBySid|checkDiffInvIds|valideGoods" application

rg -n "BATTERY_INVIDS_SID_LIST|SCM_BATTERY_SID|BATTERY_PO_ERROR|BATTERY_SA_ERROR" application

rg -n "CONVERSATION_NO_VIN_BATTERY|INTENT_BATTERY_PARAMS|searchBatteryGoods" \
  application/Services/MoveMall application/Providers

rg -n "enable_ai_parse_battery|batteryQueryParams|BatteryQueryService" application

36. 故障树

flowchart TD
  A["电池业务异常"] --> B{"经营权限还是查询识别"}
  B -->|经营权限| C{"身份是否正确"}
  C -->|否| C1["主账号 isBattery/重复主账号"]
  C -->|是| D{"范围是否正确"}
  D -->|否| D1["分类品牌/第三方归属/删除状态"]
  D -->|是| E{"缓存是否一致"}
  E -->|否| E1["TTL/空集合/未主动失效"]
  E -->|是| F["业务入口 type/供给/库存/价格/活动"]
  B -->|查询识别| G{"AI 电池开关和参数是否完整"}
  G -->|否| G1["总开关/细分开关/AI结果"]
  G -->|是| H{"销售服务是否返回 rows"}
  H -->|否| H1["参数映射/品牌/Ah/OE/CCA"]
  H -->|是| I["MoveMall 商品后处理和报价过滤"]

37. 已识别风险

编号风险影响
R42-01身份查询依赖 roleid=0 单条主账号重复/切换主账号时身份可能不稳定
R42-02第三方范围查询在 Service 层未显式过滤删除删除商品可能进入候选,取决于 Model 默认条件
R42-03自营+第三方合并未去重缓存和 SQL IN 可能膨胀
R42-04Redis key 的 sid/type 无分隔符可读性差,未来 type 扩展有碰撞风险
R42-05空结果不写缓存每次查库;调用方可能把空理解为不过滤
R42-06空结果不覆盖旧 key清空配置后旧范围仍可使用到 TTL
R42-07无配置变更主动失效证据最长 1 小时不一致
R42-08多入口各自实现交集/差集空集合、错误提示和 type 语义可能不一致
R42-09列表静默过滤、提交硬错误用户体验和排查结果不一致
R42-10当前范围限制历史退货合法历史交易可能无法退货
R42-11菜单配置在登录期计算身份变更后需重登,且隐藏不等于授权
R42-12checkGoodsMessage 重复 ID 只定位首行导入/批量错误行可能不完整
R42-13电池 AI 查询开关在客户配置易与电池站身份混淆
R42-14ampereHour 删除所有非数字小数、范围或多容量表达可能失真
R42-15V1/V2 电池查询实现并存参数、排序、异常降级可能不一致
R42-16BatteryStrategy 反射调用旧私有能力旧实现改名/签名会在运行时失败
R42-17Provider 异常和空结果语义需区分外部故障可能被显示为“无商品”
R42-18查询候选与经营权限链不一定同层执行可能查到后在报价/下单才被拒绝

38. 回归清单

38.1 身份与范围

  • [ ] 普通站、电池站、主账号软删、主账号切换。
  • [ ] 自营单分类单品牌、多分类多品牌。
  • [ ] 分类匹配品牌不匹配、品牌匹配分类不匹配。
  • [ ] 配置已删除、商品已删除、商品改分类/品牌。
  • [ ] 当前站第三方商品和其他站第三方商品。
  • [ ] ALL/KZ/THIRD 三类集合及去重。
  • [ ] 无配置、空集合和旧缓存。

38.2 缓存

  • [ ] 首次未命中重建。
  • [ ] 1 小时 TTL。
  • [ ] useCache=false 强制刷新。
  • [ ] 配置新增、删除、清空后的三个 type key。
  • [ ] Redis 不可用时的降级和错误表现。
  • [ ] 大范围站点 key 大小和序列化耗时。

38.3 商品和业务

  • [ ] Mongo 列表 all/kz/third。
  • [ ] ES 商品联想。
  • [ ] 前端缓存。
  • [ ] 采购查询和供给库存。
  • [ ] 采购 quantityLimitCheck 错误行。
  • [ ] 活动专用电池不能走普通采购。
  • [ ] 销售开单及 battery_sa_error。
  • [ ] 销售退货和采购售后。
  • [ ] 预售、活动、专属返利。
  • [ ] Excel 导入与错误文件。

38.4 Robot

  • [ ] AI 总开关和电池细分开关。
  • [ ] V1/V2 productType=2。
  • [ ] 型号、国标型号、类型、外壳、电极、Ah、OE、CCA 单字段和组合。
  • [ ] Ah 带单位、空格、范围和小数。
  • [ ] 品牌单选、多选和无匹配。
  • [ ] 销售服务成功、空 rows、超时、异常。
  • [ ] 电池站白名单内/外商品。
  • [ ] 普通站电池查询。
  • [ ] 商品有库存有价、无库存、无价。
  • [ ] 品牌排序和同品牌销量排序。

39. 改动影响面

改动最小回归
isBatteryStation菜单、所有商品查询、采购、销售、退货、导入
分类品牌表三类缓存、Mongo/ES、采购销售、历史退货
Redis key/TTL所有依赖范围的入口和刷新工具
getIdsBySid自营/第三方边界、删除状态、去重
checkDiffInvIds/valideGoods行号、错误码、批量导入和退货
BatteryQueryService腾讯 IM AI 回调、参数映射、销售服务契约
RobotInquirySerV1 微信查询、排序、商品后处理和报价
MoveMallGoodsSer电池、轮胎、油品及普通询价共同回归

40. 治理建议

40.1 统一范围对象

将散落的数组返回升级为明确对象:

BatterySaleScope
  stationId
  isBatteryStation
  kzInvIds
  thirdInvIds
  allInvIds
  sourceVersion
  generatedAt
  emptyPolicy

调用方不再用“数组是否为空”猜业务语义。

40.2 主动失效

配置、身份、商品分类品牌或删除状态变化后,发布 battery_scope_changed 事件,统一失效 Redis、Mongo/ES/前端缓存,并记录范围 diff。

40.3 历史退货例外

退货校验应优先验证原销售/采购单中的商品和数量,再决定是否受当前经营范围限制。允许历史合法交易退货,不代表允许新采购或销售。

40.4 查询与经营权限显式分层

Robot 的 isBattery 建议改名为 isBatteryQueryScenario,避免与 isBatteryStation 混淆。

41. 生产待确认项

  1. 电池站身份和分类品牌配置的真实管理入口、Owner 与审批流程。
  2. t_sys_admin 是否保证每站唯一有效 roleid=0 主账号。
  3. t_bs_battery_category_brand 的唯一索引和全部字段。
  4. GoodsModel::getList() 是否默认过滤第三方已删除/禁用商品。
  5. 空配置的正式业务语义:禁止全部、允许全部还是配置异常。
  6. 配置变更是否已有 Redis 主动失效机制。
  7. 各入口在 Redis 故障时是查 DB、放行还是拒绝。
  8. 电池范围最大 invId 数量、Redis value 大小和 SQL/ES terms 上限。
  9. 历史退货是否必须受当前可售范围限制。
  10. 电池站菜单隐藏的完整清单与后端接口权限。
  11. 销售服务 searchBatteryGoods 的完整请求/响应、错误码、超时和鉴权。
  12. ampereHour、cca 的单位、范围和模糊匹配规则。
  13. 辅助电池 C20103 是否完整支持无 VIN 查询。
  14. Robot V1/V2 当前流量比例和 BatteryStrategy 兼容链是否仍启用。
  15. MoveMallGoodsSer 是否在所有平台统一执行电池站经营范围。

42. 证据索引

主题代码路径
电池站核心服务application/Services/BatteryUser/BatteryUserService.php
自营范围 SQLapplication/models/bs/BatteryModel.php
主账号身份application/models/bs/AdminModel.php
Redis keyapplication/KzData/Enums/RedisKeys.php
范围类型application/KzData/Enums/KzEnums.php
错误码application/KzData/Enums/BatteryEnums.php
菜单application/config/menus_config.php
采购查询application/Services/PoOrders/PoMaterielSer.php
采购校验application/controllers/scm/InvPo.php
销售校验application/service/scm/InvSaService.php
销退application/Services/InvSa/NormalSaleReturnSer.php
采购售后application/controllers/po/AfterSale.php
商品列表application/service/bs/InventoryService.php
前端商品缓存application/Services/Materiels/MaterielFrontCacheSer.php
预售application/service/scm/InvPreService.php
返利application/Services/ExclusiveRebate/ExclusiveRebateSer.php
V1 AI 输入application/Services/MoveMall/RobotInquiryInput.php
V1 电池搜索application/Services/MoveMall/RobotInquirySer.php
V2 AI 适配application/Services/MoveMall/RobotV2/Adapters/Input/TencentImAdapter.php
V2 查询application/Services/MoveMall/RobotV2/Services/Query/BatteryQueryService.php
销售服务 Providerapplication/Providers/SaleService/OfferProvider.php

43. 最终理解

flowchart LR
  A["身份准确"] --> F["电池业务正确"]
  B["经营范围准确"] --> F
  C["缓存及时一致"] --> F
  D["各业务入口统一硬校验"] --> F
  E["查询参数和外部搜索可靠"] --> F

电池专题的正确性不是“页面只显示电池”这么简单。它要求身份、范围、缓存、搜索、采购、销售和退货在同一时刻对同一个商品给出一致结论;同时又要把“客户正在询问电池”与“服务站是电池专卖站”分开建模,避免查询能力和经营权限互相污染。

请求-日志-数据变更追踪卡

多入口请求链路

场景调用方与入口请求载荷/上下文Controller/ConsumerService/Provider汇合点最终业务事实
电池站身份维护OPS/用户管理sid、管理员/站点类型、启停管理入口BatteryUserServicestation/admin ID电池专卖身份和可售范围配置
商品查询/询价PC/E站/Robotsid、SKU/关键词、询价场景商品/Robot ControllerMateriel Cache/Robot Inquirysid+SKU身份范围过滤后的电池商品
采购/预订单InvPo/InvPreService电池站、SKU、数量采购/预订单 ControllerPoMaterielSer/采购 Servicepurchase/preorder bill仅允许范围内商品采购
销售/退货/导入InvSaService/Return/Import销售/原单、SKU、数量销售/导入入口Sale/Return/Import Servicesale/return bill销售和退货沿同一商品身份规则

日志证据矩阵

| 链路段 | 日志来源 | 可检索锚点 | 成功信号 | 失败信号 | 与下一段关联方式 | | --- | --- | --- | --- | --- | --- | --- | | 身份判定 | BatteryUserService | request_id、sid、admin type、battery flag | DB/Redis/Session 结论一致 | 身份缓存旧、角色与站点混淆 | sid 进入商品范围判断 | | 搜索过滤 | MaterielFrontCache/Robot | sid、SKU、query、scope key | 仅返回允许电池商品 | ES/cache/DB 口径不同 | SKU 进入采购销售校验 | | 业务校验 | Po/Sa/Return Service | business bill、sid、SKU、battery category | 合法业务 commit | 页面可见但下单拒绝或反之 | billNo 查主明细 | | 配置刷新 | Battery Service/Cache | station、config version、Redis key | 变更后各入口同时生效 | 局部缓存未清 | version/key 与请求时间对照 |

环节数据变更台账

步骤代码位置事务读取事实写入表/缓存/MQ字段或数量变化回查证据
配置身份BatteryUserService/BatteryModel/AdminModel配置事务站点/管理员原类型电池/管理员配置表、Redisbattery flag/type old -> newsid/admin、操作人、更新时间
刷新范围Battery/Cache Servicecommit 后缓存操作新身份、商品分类/范围Redis/商品缓存/搜索scope key/doc old -> newkey version、DB/缓存集合差
查询校验Materiel/Robot Service只读用户询问场景、站点经营身份、SKU 属性无两种身份分别计算;业务表 0 变化sid+SKU+scene 判定结果
采购销售Po/Sa Service领域事务身份、范围、订单商品采购/销售主明细合法才 insert;状态/数量按正常链路billNo、SKU、身份快照
退货NormalSaleReturnSer退货事务原销售事实而非当前搜索范围退货/库存表returnQty +n、库存 +n;不因后续身份变化阻断合法退货original+return bill、库存流水

子模块追踪:battery-identity 电池站身份

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
配置身份OPS/管理员设置自营或第三方电池站sid/admin、battery type/flag、operatorapplication/Services/BatteryUser/BatteryUserService.php站点/管理员原类型、资格和使用中订单配置本地事务 battery flag/type old -> newrequest ID + sid/admin + old/new type非法组合零写入;身份变更不重写历史订单
身份回查页面/接口识别不一致sid/admin、cache/type versionapplication/models/bs/BatteryModel.php、application/models/bs/AdminModel.phpDB 双表配置、Redis 和用户上下文查询只读;commit 后缓存 old -> latestsid/admin + DB/cache versions双表不一致先确定权威来源;只补派生缓存

子模块追踪:battery-scope 自营与第三方可售范围

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
范围计算商品查询/订单提交sid、identity type、SKU/categoryapplication/KzData/Enums/BatteryEnums.php电池身份、商品属性、经营范围和例外规则查询只读;分别计算 allow/denyrequest ID + sid/type + SKU + matched rule两种身份规则不可合并兜底;无命中明确不可售
范围维护商品/站点范围配置变更scope ID、type、SKU setapplication/Services/BatteryUser/BatteryUserService.php原范围、商品状态和冲突配置本地事务 old scope set -> newrequest ID + scope/type + item count全量替换失败整批回滚;历史单据不受当前范围改写

子模块追踪:battery-cache 可售 SKU Redis 缓存

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
刷新缓存身份/范围/商品变化sid/type、SKU set、cache key/versionapplication/KzData/Enums/RedisKeys.php -> application/Services/Materiels/MaterielFrontCacheSer.phpDB 身份与范围、旧 Redis 集合commit 后事务外 old set -> new set/deleted,DB 不变task/event + key + before/after count全部取消也必须清旧 key;TTL/键前缀需环境确认
差异回查DB 可售但页面不可售sid、SKU、keyapplication/Services/BatteryUser/BatteryUserService.phpDB allow 结果、Redis 成员和更新时间查询只读 不写sid/SKU + key hit + timestampsDB 正确只重建目标身份缓存,不改业务表

子模块追踪:battery-search 商品列表与搜索过滤

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
搜索列表电池用户浏览/搜索商品sid、query/SKU、identity typeapplication/Services/Materiels/MaterielFrontCacheSer.php搜索命中、身份范围、商品状态、价格库存查询只读;raw hits 经 battery scope 过滤request ID + query + raw/final hits + sid/typeraw 有 final 无是范围过滤,不直接重建索引
商品复核列表与详情/机器人不同sid、SKU、sceneapplication/Services/BatteryUser/BatteryUserService.php各入口身份上下文和同一 scope 判定查询只读 不写sid/SKU + scene decisions统一复用范围 Service;修调用方上下文而非硬编码白名单

子模块追踪:battery-purchase 采购查询与提交校验

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
采购查询电池采购商品/预订单查询sid/type、SKU、source sceneapplication/Services/PoOrders/PoMaterielSer.php当前身份、采购可售范围、供应库存和商品态查询只读 不写request ID + sid/type + SKU + supply result搜索可见不等于可采购;提交前再验范围和库存
采购提交PC/App 创建电池采购po/source billNo、sid snapshot、SKUapplication/controllers/scm/InvPo.php提交时身份、范围、价格库存和来源唯一性采购本地事务合法才 insert,保存身份/来源快照request ID + po/source + sid/type + SKU身份变化/范围失效零写入;历史单按创建快照履约

子模块追踪:battery-sale-return 销售、退货与采购售后

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
销售与采购售后新销售/采购售后请求original/source billNo、sid/type、SKUapplication/service/scm/InvSaService.php、application/controllers/po/AfterSale.php新单当前范围;售后原单快照、可退量和状态领域本地事务合法时 sale/aftersale none -> createdrequest ID + source/original/new billNos售后以原单事实为准,不因当前不可售直接拒绝
销售退货原销售发起退货入库original/return billNo、SKU、qtyapplication/Services/InvSa/NormalSaleReturnSer.php原有效出库、已退量和库存维度退货与库存本地事务 returnQty +n、库存 qty +nrequest ID + original/return/inventory billNos当前身份变化不阻断合法退货;重复原单键 0 新单

子模块追踪:battery-import 电池商品导入

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
文件校验导入电池可售商品/销售出库商品file/task、rowNo、SKU/typeapplication/controllers/basedata/Import.php -> application/Services/Import/Type/SaleOutGoodsService.php表头、商品、电池属性、范围、文件内/库内重复校验阶段不写;形成 valid/error rowstask + row/SKU + errors错误带行号;不允许部分列错位静默落库
批量落库校验通过后更新范围/商品batch、SKU list、scopeapplication/Services/BatteryUser/BatteryUserService.php当前配置和幂等键每批本地事务 old item/scope -> imported new;缓存 commit 后刷新task/batch + affected + cache result只重跑失败 SKU;DB 成功不重复导入

子模块追踪:battery-robot V1/V2 机器人电池查询

环节入口/触发请求/业务键代码链路读取事实写入与字段变化日志证据异常与补偿
V1 查询旧机器人识别电池询价conversation/request、sid、query/SKUapplication/Services/MoveMall/RobotInquirySer.php -> application/Services/MoveMall/RobotInquiryInput.php用户身份、解析商品、范围、价格库存查询只读;保存会话查询日志,不改商品request + conversation + sid/SKU + result无权限/无结果明确返回,不绕过 scope
V2 查询腾讯 IM RobotV2 电池策略message/conversation、sid、normalized inputapplication/Services/MoveMall/RobotV2/Services/Query/BatteryQueryService.php -> application/Services/MoveMall/RobotV2/Strategies/BatteryStrategy.phpAdapter 用户上下文、统一范围和商品事实查询只读;回复记录本地事务 pending -> sent/failedmessage + conversation + query/result IDsV1/V2 使用同一身份判断;发送失败只补回复