# G3 技术专项验证报告 本文档记录 [24-g3-g4-decision-register.md](24-g3-g4-decision-register) 中专项验证的只读事实、结论和设计回写。专项验证只读取源码、数据库结构和运行页面,不编写业务代码,不修改数据库业务数据。 ## 一、状态总览 | 编号 | 验证主题 | 状态 | 结论去向 | | --- | --- | --- | --- | | `SPIKE-001` | CRMEB 商品规格身份稳定性 | 已完成 | 本文第二节;回写 `15`、`19`、`21`、`22`、`24`、`28` | | `SPIKE-002` | 普通订单创建、支付、退款扩展点 | 已完成 | 本文第三节;回写 `15`、`19`、`20`、`21`、`22`、`24`、`28` | | `SPIKE-003` | 用户余额和商户账单入账接口 | 已完成 | 本文第四节;回写 `15`、`19`、`20`、`21`、`22`、`24`、`26`、`28` | | `SPIKE-004` | 独立补运费单支付能力 | 已完成 | 本文第五节;回写 `15`、`19`、`20`、`21`、`22`、`24`、`26`、`28` | | `SPIKE-005` | uni-app 页面、组件、DIY 和秒杀链路 | 已完成 | [25-user-uniapp-reuse-audit.md](25-user-uniapp-reuse-audit) | | `SPIKE-006` | 服务端认证、路由和移动布局 | 已完成 | 本文第六节;回写 `15`、`17`、`18`、`19`、`20`、`21`、`22`、`24`、`26`、`28` | | `SPIKE-007` | 地图跨端兼容边界 | 已完成 | 本文第七节;回写 `06`、`10`、`14`、`15`、`16`、`17`、`18`、`19`、`21`、`22`、`24`、`25`、`26`、`28` | ## 二、SPIKE-001:商品规格身份稳定性 ### 1. 要回答的问题 1. CRMEB 的 `store_product_attr_value.value_id` 是否能作为云仓活动 SKU 的长期身份。 2. `unique` 在商品编辑、规格修改和商品复制后是否保持不变。 3. 来源商品变化后,草稿活动、已发布活动和历史订单分别如何处理。 ### 2. 只读源码证据 | 证据 | 当前源码位置 | 事实 | | --- | --- | --- | | 规格值主键 | `smartfarm/app/common/model/store/product/ProductAttrValue.php:27` | `store_product_attr_value` 使用自增 `value_id` 作为当前行主键 | | 商品保存 | `smartfarm/app/common/repositories/store/product/ProductRepository.php:480` | 保存规格时先调用 `clearAttr($product_id)`,再批量新增规格值 | | 删除规格值 | `smartfarm/app/common/dao/store/product/ProductAttrValueDao.php:105` | `clearAttr` 按 `product_id` 删除现有规格值行 | | `unique` 生成 | `smartfarm/app/common/repositories/store/product/ProductRepository.php:944` | `unique` 由规格字符串、商品 ID 和商品类型计算 | | 规格载荷构建 | `smartfarm/app/common/repositories/store/product/ProductRepository.php:763` | 新增规格值时重算 `sku` 和 `unique`,不保留原 `value_id` | | 购物车关联 | `smartfarm/app/common/model/store/StoreCart.php:57` | 普通商城运行时可按 `unique` 查当前规格,但这不代表其是不可变历史主键 | | 商品复制载荷 | `smartfarm/app/common/repositories/store/product/ProductCopyRepository.php:201` | 复制后以新商品载荷重新保存,商品 ID 和规格值行都会重建 | 由这些事实可以确定: - 普通商品完整编辑会删除并重建规格值,未改变的规格也可能获得新的 `value_id`。 - 当商品 ID、商品类型或规格字符串变化时,`unique` 会变化。 - 商品复制会使用新的商品 ID,因此复制商品的 `unique` 与规格值主键都不是原商品的延续。 - `value_id` 和 `unique` 适合定位“当前 CRMEB 规格”,不适合作为云仓订单、批次、持仓和账本的永久外键。 ### 3. 冻结结论 `G3-INV-001` 确认为以下规则: 1. `eb_farm_cloud_activity_sku.activity_sku_id` 是云仓活动 SKU 唯一、稳定的业务身份。 2. 批次、订单明细、持仓、二次库存、分配记录、回购和账本只关联 `activity_sku_id` 及各自快照。 3. `source_product_id`、`source_value_id`、`source_unique` 和规范化规格文本只用于来源核验、草稿重匹配和运营诊断。 4. 草稿或未发布活动允许重新核验来源 SKU;不存在或匹配不唯一时标记 `unresolved`,禁止发布并要求平台运营重新选择。 5. 已发布活动不自动重匹配、不自动同步价格和规格规则;来源商品后续修改、删除或复制均不改变已发布活动、批次和历史交易。 6. 需要采用新来源规格时,关闭未开始的旧配置并新建活动 SKU;不得覆盖已产生订单的快照。 ### 4. 来源同步状态 活动 SKU 保存以下诊断字段: | 字段 | 用途 | | --- | --- | | `source_product_id` | 当前来源商品 ID | | `source_value_id` | 最近一次核验到的 CRMEB 规格值行 ID | | `source_unique` | 最近一次核验到的 CRMEB `unique` | | `source_sku_text` | 规范化后的规格组合文本 | | `source_snapshot` | 发布时的商品名、规格、编码、图片、单位和来源价格快照 | | `source_snapshot_hash` | 判断草稿来源是否发生变化 | | `source_sync_status` | `matched`、`changed`、`missing`、`ambiguous`、`unresolved` | | `source_checked_at` | 最近一次来源核验时间 | `source_sync_status` 只描述来源健康度,不替代活动业务状态。已发布活动即使来源变为 `missing`,历史交易仍按活动快照继续执行;后台显示告警并限制创建新活动或追加新批次。 ### 5. 采用方案与替代方案 | 方案 | 结论 | 原因 | | --- | --- | --- | | 独立 `activity_sku_id` + 来源快照 | 采用 | 历史身份稳定,能隔离 CRMEB 规格重建 | | 永久关联 `value_id` | 否决 | 商品编辑会删除重建,历史外键会失真 | | 永久关联 `unique` | 否决 | 商品 ID、类型或规格字符串变化会导致值变化 | | 每次读取 CRMEB 当前商品覆盖活动数据 | 否决 | 会改变已付款用户的价格、规则和展示依据 | | 复制整套 CRMEB 商品为云仓商品 | V1 不采用 | 增加商品维护和同步成本,仍不能替代活动规则快照 | ### 6. 数据、接口和代码影响 - 数据模型:活动 SKU 明确独立主键、来源诊断字段、发布快照和同步状态索引。 - 发布接口:发布前重新核验所有活动 SKU;`missing`、`ambiguous`、`unresolved` 一律失败。 - 草稿编辑:提供单 SKU 和整活动的只读来源重检;只有运营明确确认后才更新草稿来源引用。 - 已发布详情:同时返回活动快照与来源健康告警,前端不得用 CRMEB 当前规格覆盖活动快照。 - Repository:`CloudActivitySkuRepository` 管理稳定身份与快照;CRMEB `ProductAttrValueRepository` 只作为当前来源查询适配器。 - 审计:记录核验前后标识、状态、操作者、时间和活动状态,不记录敏感信息。 ### 7. 必测场景 1. 保存未改规格的来源商品后 `value_id` 重建,草稿能够按规范化规格重匹配。 2. 来源规格改名或删除后,草稿变为 `changed`/`missing` 并禁止发布。 3. 出现多个候选规格时标记 `ambiguous`,系统不得静默选择。 4. 来源商品编辑、删除或复制后,已发布活动、订单、批次、持仓和账本关联不变。 5. 已发布页面显示活动快照和来源告警,不显示新商品价格替代历史价格。 6. 创建新活动可选择新的来源 SKU,但不得复用旧 `activity_sku_id`。 ## 三、SPIKE-002:订单、支付、取消与退款扩展点 ### 1. 要回答的问题 1. 云仓首次抢购如何复用 CRMEB 订单和支付,又不要求用户先选择地址或去向。 2. 二次代销普通订单在何处锁定云仓库存,才能与普通订单创建一起回滚。 3. 支付成功、未支付取消、退款申请、退款拒绝/取消和资金退款完成分别在哪个事务边界接入。 4. CRMEB 的普通商户财务、普通库存和履约副作用是否适用于云仓首次订单与二次代销订单。 ### 2. 只读源码证据 | 证据 | 当前源码位置 | 事实 | | --- | --- | --- | | 用户订单入口 | `smartfarm/app/controller/api/store/order/StoreOrder.php:83` | `v2CreateOrder` 校验购物车后使用 `LockService('order.create')` 调用订单 Repository | | 普通订单创建事务 | `smartfarm/app/common/repositories/store/order/StoreOrderCreateRepository.php:1177` | 库存扣减、订单组、子订单和订单明细在同一 `Db::transaction` 中完成 | | 订单明细完成点 | `smartfarm/app/common/repositories/store/order/StoreOrderCreateRepository.php:1417` | `StoreOrderProduct` 批量插入后仍在事务内,随后才触发 `order.create` | | 批量插入返回值 | `smartfarm/app/common/dao/BaseDao.php:219` | `insertAll` 返回批量插入结果,不返回每个订单明细 ID | | 代客下单独立路径 | `smartfarm/app/common/repositories/store/order/MerchantOrderCreateRepository.php:276` | 商户代客下单有另一套创建事务,不经过用户端 `StoreOrderCreateRepository` | | 支付成功事务 | `smartfarm/app/common/repositories/store/order/StoreOrderRepository.php:282` | `paySuccess` 在事务内更新订单、生成商户锁定款和财务记录 | | 普通商户入账副作用 | `smartfarm/app/common/repositories/store/order/StoreOrderRepository.php:396` | 普通订单按订单实付、佣金和平台券生成财务流水,并调用 `addLockMoney` | | 支付成功其他副作用 | `smartfarm/app/common/repositories/store/order/StoreOrderRepository.php:282-590` | 同一流程还处理普通活动、卡密、同城配送、推广关系、商户通知/打印、用户支付统计、积分、赠券、短信、会员值和数据大屏 | | 支付后事件 | `smartfarm/app/common/repositories/store/order/StoreOrderRepository.php:591` | `order.paySuccess` 在核心支付事务提交后触发,当前不能作为原子事实写入点 | | 外部支付回调防重 | `smartfarm/crmeb/listens/pay/OrderPaySuccessListen.php:25` | 回调先检查 `groupOrder.paid`,再调用 `paySuccess`;没有云仓业务幂等 | | 支付参数 | `smartfarm/app/common/model/store/order/StoreGroupOrder.php:73` | 普通、合单支付均使用订单组编号和 `attach=order`,可继续复用现有支付入口 | | 未支付取消 | `smartfarm/app/common/repositories/store/order/StoreGroupOrderRepository.php:153` | 先在事务中标记订单删除,再投递 `CancelGroupOrderJob` | | 取消库存恢复 | `smartfarm/crmeb/jobs/CancelGroupOrderJob.php:29` | 队列任务在独立事务中恢复普通商品库存和优惠券 | | 退款申请 | `smartfarm/app/common/repositories/store/order/StoreRefundOrderRepository.php:805` | 退款单、退款明细和订单明细可退数量在同一事务中更新 | | 退款同意 | `smartfarm/app/common/repositories/store/order/StoreRefundOrderRepository.php:1427` | 实际退款、退款后处理和退款状态在事务内推进,`refund.agree` 在其后触发 | | 资金退款与商户扣款 | `smartfarm/app/common/repositories/store/order/StoreRefundOrderRepository.php:2592` | 普通退款调用支付适配器后会直接扣减商户锁定款 | | 退款后副作用 | `smartfarm/app/common/repositories/store/order/StoreRefundOrderRepository.php:1721` | 普通逻辑恢复商品库存、处理分佣和商户财务记录 | | 退款拒绝/取消 | `smartfarm/app/common/repositories/store/order/StoreRefundOrderRepository.php:1374`、`:2031` | 两条路径各自在事务内恢复订单明细可退状态,用户取消路径没有统一事件 | ### 3. 关键风险 仅在 `order.paySuccess` 或 `refund.agree` 后追加监听器不够: - 事件发生在核心事务之后,事件丢失时会出现 CRMEB 已支付而云仓仍待付款。 - 普通 `paySuccess` 会把订单实付金额计入商户锁定款;云仓首次订单应按供货价另行结算,二次代销收入应进入用户本金/收益账本,直接复用会重复入账。 - 普通退款会扣减商户锁定款;云仓订单若没有普通商户入账,会产生错误扣款。 - 首次云仓订单不占普通商品库存,普通退款或取消不能把数量加回来源商品。 - 二次代销订单既要恢复 CRMEB 展示库存,又要释放云仓批次分配,两个动作必须可对账。 ### 4. 冻结架构 `G3-ARCH-001` 确认为“CRMEB 交易载体 + 农业订单绑定 + 事务内薄适配”: 1. 云仓首次购买使用独立 `CloudOrderCreateService`,不经过普通购物车确认;在一个事务中创建 CRMEB 订单组、子订单、订单明细、`FarmOrderBinding`、云仓首次明细、库存流水和幂等结果。 2. 付款继续使用 CRMEB 现有订单支付入口、支付参数和支付渠道,不新建平行支付引擎。 3. 新增 `eb_farm_order_binding`,按订单明细声明 `business_type`、`finance_policy`、`stock_policy` 和 `fulfillment_policy`,核心适配点只查询该表,不在多个业务表中猜类型。 4. `finance_policy=farm_managed` 的订单不生成普通商户锁定款、普通订单净收入、分销佣金、积分/会员值/赠券、推广资格、普通打印/短信和普通退款扣款;对应履约、通知、统计和资金事实进入农业模块。 5. 支付成功事务内由 `FarmOrderPaymentAdapter` 只做幂等状态确认和 outbox 写入;复杂状态推进、通知和结算仍异步执行。 6. 退款的三个申请入口、拒绝、取消、`executeRefund()` 扣商户锁定款前和 `refundAfter()` 分别调用 `FarmOrderRefundAdapter`,不能把 `refund.agree` 当成唯一事实来源。 7. 未支付取消由 `FarmOrderCancelAdapter` 在 `CancelGroupOrderJob` 的任何普通库存/活动库存/优惠券恢复之前返回恢复策略,再释放对应云仓锁定。 8. `order.paySuccess`、`refund.agree` 等现有事件只用于唤醒消费者和兼容修复,业务表与 outbox 才是事实来源。 ### 5. 三类订单策略 | 订单策略 | 普通商品库存 | 普通商户财务 | 履约 | 农业适配 | | --- | --- | --- | --- | --- | | `normal` | CRMEB 扣减/恢复 | 完整保留 | CRMEB 普通履约 | 快速跳过 | | `cloud_primary` | 不扣、不恢复来源商品库存 | 跳过;商户货款按供货价进入云仓供货账本 | 付款后待选邮寄/自提/代销 | 活动库存、首次明细、去向和商户供货结算 | | `cloud_resale` | 扣减/恢复 CRMEB 镜像库存 | 跳过;销售收入进入云仓批次与用户账本 | 平台云仓履约 | 创建时 FEFO 分配,支付/完成/退款推进分配 | V1 约束: - 一个 CRMEB `group_order_id` 不得混合 `normal` 与 `farm_managed` 明细;确认订单时拆成不同订单组或提示分别提交。原因是支付统计、赠券、会员值和部分提交后副作用按订单组执行。 - `cloud_only` 映射 SKU 不参加代客下单、积分商城或其他营销活动,避免绕过云仓分配事务。 - 云仓首次订单一单一活动 SKU;二次代销订单可以包含多个 `cloud_only` SKU,但全部必须由同一云仓履约主体处理。 ### 6. 精确接入点 | 流程 | 同步接入点 | 事务内动作 | | --- | --- | --- | | 首次抢购创建 | `CloudOrderCreateService` 自有事务 | 条件扣减活动库存,创建 CRMEB 三层订单、绑定、首次明细、库存流水、幂等记录和 outbox | | 二次普通订单创建 | `StoreOrderCreateRepository` 在订单明细 `insertAll` 后、`order.create` 前 | 按 `order_id` 回查明细,创建绑定,FEFO 锁定批次和库存流水;失败则整单回滚 | | 支付成功 | `StoreOrderRepository::paySuccess` 核心事务结束前 | 根据绑定执行财务/履约策略,确认首次或二次付款事实并写 outbox | | 未支付取消 | `CancelGroupOrderJob` 进入任何恢复动作之前 | 取得 `restore_product_stock/restore_activity_stock/restore_coupon` 决策;首次只释放活动锁,二次恢复镜像库存和 FEFO 分配 | | 退款申请 | 退款单创建事务 | 冻结首次选择或二次分配,记录申请事实 | | 退款拒绝/取消 | 各自状态事务 | 解冻首次选择或二次分配,恢复观察期 | | 退款资金执行 | `StoreRefundOrderRepository::executeRefund()` 调用 `subLockMoney()` 之前 | `farm_managed` 跳过不存在的普通商户锁定款扣减;`normal` 保持原逻辑 | | 退款后处理 | `refundAfter()` 事务 | 首次不恢复来源商品库存;二次恢复镜像库存和 FEFO 分配;平台券按原规则恢复;写农业反向流水和 outbox | ### 7. 采用方案与替代方案 | 方案 | 结论 | 原因 | | --- | --- | --- | | CRMEB 订单载体 + 统一绑定表 + 事务适配器 | 采用 | 保留支付售后能力,同时明确库存、财务和履约策略 | | 只监听 `order.paySuccess`/`refund.agree` | 否决 | 发生在事务后且退款路径不完整,无法保证原子事实 | | 首次抢购直接走普通购物车 | 否决 | 强制地址/配送、普通优惠和普通库存语义均不匹配 | | 新建完全独立的支付订单引擎 | 否决 | 重复支付、回调和退款能力,维护风险最高 | | 把业务类型塞进订单 JSON 后到处判断 | 否决 | 查询和约束弱,无法稳定支撑取消、退款与对账 | | 让普通商户财务先入账再冲正 | 否决 | 会形成短时错误余额和重复结算,异常时难以恢复 | ### 8. 必测场景 1. 首次创建任一步失败,CRMEB 订单、活动库存、首次明细和幂等记录全部回滚。 2. 二次普通订单 FEFO 分配失败,普通 SKU 扣减和订单创建全部回滚。 3. 普通商品与 `cloud_only` 商品混合提交时形成不同 `group_order_id` 或明确拒绝,不产生混合策略订单组。 4. 云仓首次和二次订单支付后不增加普通商户锁定款,不生成普通订单净收入。 5. 同一支付回调重复十次,云仓付款事实和 outbox 只生成一次。 6. 首次未支付取消不增加来源普通商品库存;二次未支付取消同时恢复镜像库存和云仓分配。 7. 退款申请后冻结业务动作,拒绝或用户取消后正确解冻。 8. 首次实际退款只释放活动/首次库存;二次实际退款同时恢复镜像库存并冲减云仓分配。 9. `executeRefund()` 对普通订单仍按原逻辑扣减商户锁定款,对两类云仓订单在扣款前分流。 10. 普通商城订单的创建、支付、取消、退款、库存、券、积分、会员值、推广、打印、短信、同城配送、卡密、统计和商户财务结果与改造前完全一致。 ## 四、SPIKE-003:用户余额与商户供货货款正式入账 ### 1. 要回答的问题 1. 用户云仓本金、收益和回购款最终如何进入 CRMEB 可用余额。 2. 商户供货货款在满足验收、首次有效购买和观察期后如何进入可提现余额。 3. 现有 `UserBill`、商户冻结款和 `FinancialRecord` 能否直接承担幂等、业务账本和结算周期。 4. 重复任务、并发入账、事务失败、冲正和金额字段上限如何处理。 ### 2. 只读源码与数据库证据 | 证据 | 当前源码或数据库位置 | 事实 | | --- | --- | --- | | 用户账单写入 | `smartfarm/app/common/repositories/user/UserBillRepository.php:243` | `bill/incBill/decBill` 只创建 `eb_user_bill`,不修改 `eb_user.now_money` | | 后台余额调整 | `smartfarm/app/common/repositories/user/UserRepository.php:706` | `changeNowMoney` 在事务中改余额并写 `sys_inc_money/sys_dec_money`,但语义是人工系统调整,且没有业务来源幂等键 | | 充值入账 | `smartfarm/app/common/repositories/user/UserRechargeRepository.php:199` | 充值回调以充值单 `paid` 防重,在事务中写账单和余额;不能代替云仓账本资格校验 | | 商户可用余额 | `smartfarm/app/common/repositories/system/merchant/MerchantRepository.php:113` | `addMoney` 只增加 `mer_money`,不创建商户财务流水;调用时必须经现有核心字段写入许可 | | 商户冻结款 | `smartfarm/app/common/repositories/system/merchant/MerchantRepository.php:907` | `addLockMoney` 受全局 `mer_lock_time` 控制,启用时只创建冻结账单 | | 商户自动解冻 | `smartfarm/crmeb/listens/AutoUnlockMerchantMoneyListen.php:24` | 每 20 分钟按冻结账单创建时间和全局天数解冻,不能表达不同云仓供货账本各自的 `eligible_at` | | 商户财务流水 | `smartfarm/app/common/dao/system/merchant/FinancialRecordDao.php:61` | `inc/dec` 只创建 `eb_financial_record`,自身不修改商户余额 | | 普通订单财务 | `smartfarm/app/common/repositories/store/order/StoreOrderRepository.php:396` | 普通订单同时生成多条财务记录并创建商户冻结款,金额口径是普通订单净收入,不是云仓供货价 | | 用户账单索引 | 当前库 `information_schema.STATISTICS` | `category + type + link_id` 只是普通索引,没有唯一约束 | | 商户流水索引 | 当前库 `information_schema.STATISTICS` | `financial_record_sn`、来源业务号均没有唯一约束,不能作为并发防重设施 | | 当前金额字段 | 当前库 `information_schema.COLUMNS` | `user.now_money` 为 `decimal(8,2)`,`financial_record.number` 为 `decimal(8,2)`,`merchant.mer_money` 为 `decimal(12,2)` | | 当前 SQL 模式 | 当前库 `@@GLOBAL.sql_mode` | 未启用严格模式,金额溢出不能依赖数据库抛错,应用必须显式校验且上线前扩大字段容量 | 由这些事实可以确定: - 现有账单和财务流水是最终账户的展示记录,不是云仓业务资格、公式、审核和幂等事实。 - 用户入账不能调用 `changeNowMoney` 冒充后台人工调账,也不能复用充值单语义。 - 商户货款到 `eligible_at` 时已经完成云仓自己的观察期,再进入全局商户冻结期会造成重复等待。 - `UserBill` 和 `FinancialRecord` 都没有满足云仓 exactly-once 要求的唯一键,必须由独立入账桥接记录提供并发防重。 - 现有余额写法普遍是“读取当前值后保存”,专用入账事务必须先锁定目标账户行,避免与充值、消费、普通订单解冻并发时丢失更新。 ### 3. 冻结架构 `G3-CODE-003`、`G3-FIN-002` 和 `G3-CODE-007` 确认为“业务账本/结算单 + 入账桥接 + CRMEB 最终账户”: ```text 用户云仓账本 -> eb_farm_financial_posting -> eb_user.now_money + eb_user_bill 商户供货账本 -> 商户日结单及明细 -> eb_farm_financial_posting -> eb_merchant.mer_money + eb_financial_record ``` 统一新增 `eb_farm_financial_posting`,保存来源类型、来源 ID、入账方向、目标账户、金额、唯一 `posting_key`、处理状态、CRMEB 流水 ID、入账前后余额和失败信息。业务账本仍是计算来源,入账记录只负责把一笔已获资格的金额安全映射到最终账户。 冻结规则: 1. 一个来源账本或结算单的同一版本、同一方向只能有一个成功入账记录。 2. 重试只重用原 `posting_key`;成功记录再次执行直接返回首次结果。 3. 目标账户、CRMEB 流水和入账记录必须在同一个数据库事务中完成。 4. 原成功入账不修改、不删除;冲正创建反向入账记录并关联 `reversal_of_posting_id`。 5. 现有 `UserBill`、`FinancialRecord` 的非唯一来源字段只用于展示和追查,防重以 `eb_farm_financial_posting` 唯一键为准。 ### 4. 用户余额入账事务 新增 `FarmUserBalancePostingService`,一次只处理一条用户业务账本: 1. 按 `posting_key=farm_user_ledger:{ledger_id}:credit:v{version}` 创建或锁定入账记录。 2. `SELECT ... FOR UPDATE` 锁定用户账本,校验状态为可入账、金额大于零、无冻结、无未决退款或对账差异。 3. 锁定 `eb_user` 目标行,读取入账前余额并显式校验目标字段容量。 4. 增加 `now_money`,再调用 `UserBillRepository::incBill` 写入 `category=now_money`、`type=farm_cloud_settlement`、`link_id=ledger_id` 的流水。 5. 保存 `bill_id`、入账前后余额、完成时间,并把业务账本推进为已结算。 6. 同一事务写入 Outbox;用户通知和短信在提交后消费,不作为入账成功条件。 `UserBillRepository::TYPE_INFO` 增加 `now_money/farm_cloud_settlement=云仓结算入账`。用户账本详情继续分别展示本金、收益和回购款,CRMEB 余额流水只展示本次合计,不能用一条余额流水反推业务公式。 ### 5. 商户货款入账事务 商户货款采用“按日结单、直接进入可用余额”的方案: 1. 每日 `02:00` 按商户汇总前一自然日 `eligible_at` 已到期且未入单的供货账本;生成一张 `eb_farm_merchant_supply_statement` 和逐账本明细。 2. 结单金额为所有明细 `settle_amount` 之和;结单生成后明细不可替换,差异通过调整单和下一张结单处理。 3. `FarmMerchantBalancePostingService` 以 `posting_key=farm_merchant_statement:{statement_id}:credit:v{version}` 锁定结单、入账记录和商户账户行。 4. 通过现有 `MerchantRepository::addMoney` 增加 `mer_money`,保留核心字段写入许可;由于账户行已在同一事务中锁定,不会与普通订单自动解冻并发覆盖。 5. 调用 `FinancialRecordRepository::inc` 创建 `financial_type=farm_supply_settlement`、`financial_pm=1`、`type=0` 的商户财务流水,`order_id` 使用结单主键,`order_sn` 使用结单号。 6. 保存 `financial_record_id`、入账前后余额和完成时间,同时推进结单及其明细为已入账。 不调用 `addLockMoney`,也不创建 `mer_computed_money`:供货账本自身的 `eligible_at` 已经包含验收、首次有效购买和观察期,再套用全局冻结天数没有业务依据。二次代销进度和是否滞销仍不影响商户已满足条件的供货货款。 ### 6. 自动审核、冲正与容量 V1 自动入账默认阈值: | 对象 | 默认自动上限 | 超限处理 | | --- | --- | --- | | 单条用户入账 | `10,000.00` 元 | 转人工复核,不改变应结金额 | | 单用户自然日累计 | `50,000.00` 元 | 当日后续记录转人工复核 | | 单张商户结单 | `100,000.00` 元 | 转人工复核 | 阈值使用系统配置,只有超级管理员可修改并记录审计;规则快照不完整、金额不闭合、有冻结/退款/争议或来源版本变化时,无论金额大小都必须人工。 冲正规则: - 正常经营亏损、滞销、平台优惠、仓储损耗和平台原因售后不得向用户或商户最终账户发起借方冲正。 - 只有已审核确认的重复/错误入账,或外部支付最终撤销且原权益不再成立,才先创建 `eb_farm_cloud_ledger_adjustment`,审核后生成新的借方入账记录。 - 自动冲正不得使 `now_money` 或 `mer_money` 小于零;余额不足时进入 `recovery_pending`,冻结后续同主体农业入账并由后续可得款先抵扣。 - 不自动部分扣款,不直接改原账本金额,不使用负数写入 unsigned 字段。 - 退款发生在正式入账前时,冻结或重算原业务账本,不先入账再冲回。 上线前执行只扩容、不改语义的字段升级: - `eb_user.now_money`、`eb_user_bill.number`、`eb_user_bill.balance` 扩为 `decimal(14,2) unsigned`。 - `eb_merchant.mer_money` 扩为 `decimal(14,2)`。 - `eb_financial_record.number` 扩为 `decimal(14,2)`。 - 所有新增农业金额字段使用 `decimal(14,2)`;中间比例计算使用更高精度,最终入账按分舍入。 即使字段已扩容,服务仍需校验金额、目标余额和实际更新后的余额,不能依赖当前非严格 SQL 模式处理溢出。 ### 7. 采用方案与替代方案 | 方案 | 结论 | 原因 | | --- | --- | --- | | 独立入账桥接 + CRMEB 最终账户 | 采用 | 同时获得业务可审计、数据库幂等和现有余额/提现复用 | | 用户直接调用后台 `changeNowMoney` | 否决 | 类型和操作者语义错误,没有云仓来源防重 | | 商户到资格日后再走全局冻结款 | 否决 | 重复等待,无法表达每条供货账本的独立资格时间 | | 只写 `UserBill`/`FinancialRecord` | 否决 | 两者都不会自动同步目标余额,也没有来源唯一约束 | | 只改余额、不写现有流水 | 否决 | 现有余额明细和商户资金页面无法解释变动 | | 在原成功记录上改金额 | 否决 | 破坏审计链,重试和对账无法判断首次事实 | ### 8. 必测场景 1. 同一用户账本并发执行十次,只增加一次余额并产生一条成功入账记录和一条 CRMEB 余额流水。 2. 用户充值、余额消费和云仓入账并发时不丢失任何一笔变化,流水末余额与账户余额一致。 3. 写余额后创建账单失败时整笔事务回滚,重试仍可成功。 4. 商户普通订单解冻与云仓结单入账并发时,两笔金额都保留。 5. 同一商户结单重跑不重复增加余额或创建财务流水。 6. 商户供货账本尚未验收、未过观察期或有首次退款时不进入结单。 7. 二次代销滞销不阻止已合格商户货款进入下一日结单。 8. 超过自动阈值时只转人工复核,不改变账本金额。 9. 冲正余额不足时不产生负余额,进入待追偿并冻结后续农业入账。 10. 扩容前后普通充值、余额消费、普通商户解冻、提现和财务报表回归通过。 ## 五、SPIKE-004:独立补运费单支付能力 ### 1. 要回答的问题 1. 补运费是否必须改写原云仓商品订单,还是可以使用独立支付单。 2. 独立支付单如何复用微信、支付宝和余额支付。 3. 支付渠道如何把成功回调准确分发给补运费业务。 4. 重复回调、支付超时、迟到付款和退款如何闭环。 5. 需要修改哪些 CRMEB 公共文件,哪些逻辑必须留在农业领域服务内。 ### 2. 只读源码证据 | 证据 | 当前源码位置 | 事实 | | --- | --- | --- | | 支付参数最小契约 | `smartfarm/app/common/model/user/UserRecharge.php:58` | 独立充值单只需提供 `order_sn`、`pay_price`、`attach`、`body` 和可选 `return_url` | | 支付驱动入口 | `smartfarm/crmeb/services/pay/Pay.php:16` | `Pay` 根据驱动名加载微信、支付宝等支付实现,不要求订单必须是商城订单 | | 支付驱动接口 | `smartfarm/crmeb/services/pay/PayInterface.php:13` | 公共接口提供支付、退款和通知能力 | | 微信支付参数 | `smartfarm/crmeb/services/pay/storage/Weixin.php:22` | H5、公众号、小程序、App 等渠道都直接消费上述支付参数,并把 `attach` 传给微信 | | 支付宝支付参数 | `smartfarm/crmeb/services/pay/storage/Alipay.php:21` | 支付宝把 `attach` 写入 `passback_params`,订单号和金额不依赖商城表 | | 微信回调分发 | `smartfarm/crmeb/services/wechat/Payment.php:888` | 验签成功后触发 `pay_success_{attach}`,并携带订单号、渠道原始数据和组合支付标记 | | 支付宝回调分发 | `smartfarm/crmeb/services/AlipayService.php:333` | 验签成功后同样触发 `pay_success_{attach}` | | 事件注册 | `smartfarm/app/event.php:98` | 充值、商城订单、预售等支付业务均通过独立事件监听器接收回调 | | 独立业务范例 | `smartfarm/crmeb/listens/pay/UserRechargeSuccessListen.php:20` | 监听器仅按订单号调用业务仓库,证明回调无需经过商城订单 | | 充值成功事务 | `smartfarm/app/common/repositories/user/UserRechargeRepository.php:201` | 充值单使用本地 `paid` 状态防重复,并在事务内更新余额、账单和支付状态 | | 余额支付范例 | `smartfarm/app/common/repositories/store/order/StoreOrderRepository.php:188` | 余额支付可直接在本地事务内扣余额、写用户账单并调用业务成功逻辑 | | 微信退款适配 | `smartfarm/crmeb/services/pay/storage/Weixin.php:140` | 可按原商户订单号、原支付金额、退款金额和退款单号发起退款 | | 支付宝退款适配 | `smartfarm/crmeb/services/pay/storage/Alipay.php:82` | 可按原商户订单号和独立退款单号发起退款 | | 通用查询限制 | `smartfarm/crmeb/services/PayStatusService.php:27` | 当前只实现扫码枪支付查询,不能直接作为所有补运费渠道的统一主动查询器 | | 通用关单限制 | `smartfarm/crmeb/services/pay/PayInterface.php:13` | 当前接口没有统一关单方法,超时后仍必须防御迟到成功回调 | 结论:CRMEB 支付层按“支付参数 + `attach` 事件”工作,不要求业务单来自 `store_group_order`。独立云仓运费单可以直接复用现有渠道,且比修改原商品订单金额更符合现有扩展方式。 ### 3. 冻结决策 `G3-CODE-004` 确认为: - 新建 `eb_farm_cloud_freight_order`,不新增 `store_group_order`、`store_order` 或 `store_order_product` 记录。 - 原云仓首次订单只记录商品本金,补运费不回写其 `pay_price`、优惠分摊或商户供货货款。 - 支付回调标识固定为 `attach=farm_cloud_freight`,事件名固定为 `pay_success_farm_cloud_freight`。 - 每张运费单对应一个云仓首次订单明细的一次地址/运费报价版本;同一明细任一时刻只能有一张当前待支付运费单。 - 地址、运费模板、计费参数和金额在创建运费单时保存快照,支付成功后不可修改。 - V1 支持用户端已启用的微信 H5/公众号/小程序/App、支付宝 H5/App和余额支付;不支持线下、扫码枪、服务商组合支付或商户子账户收款。 - 渠道收款主体为平台,后续实际物流费用属于平台履约成本,不进入用户云仓本金或代销收益。 ### 4. 支付参数与状态 `CloudFreightOrder::getPayParams()` 固定返回: ```text order_sn = freight_order_no pay_price = freight_amount attach = farm_cloud_freight body = 云仓邮寄补运费 return_url = 支付宝 H5 可选 ``` 运费单分别保存业务、支付和退款状态: | 维度 | 状态 | | --- | --- | | 业务状态 | 待支付、邮寄已确认、已取消、已超时、异常 | | 支付状态 | 未支付、支付中、已支付、迟到支付、退款处理中、已退款 | | 渠道关单状态 | 无需关单、待关单、已关单、关单失败、不支持 | 还必须保存 `freight_order_no`、订单明细、用户、选择版本、地址/运费快照、应付/实付金额、支付方式、支付驱动、渠道交易号、渠道回调时间、超时时间、当前版本和异常原因。 发起支付时不接受前端提交金额、订单号、`attach` 或用户 ID。服务端按已锁定运费单生成支付参数;切换支付渠道时取消旧的未支付运费单并生成新单号,避免一个商户订单号跨渠道留下歧义。 ### 5. 支付成功事务 在线渠道流程: ```text 渠道验签 → pay_success_farm_cloud_freight → CloudFreightPaySuccessListen → CloudFreightPaymentService::paySuccess(orderNo, callback) → 锁运费单和云仓订单明细 → 校验用户、金额、渠道交易号和当前选择版本 → 幂等记录支付事实 → 固化邮寄去向与地址/运费快照 → 创建邮寄履约单和 Outbox → 同一事务提交 ``` 金额统一规范化为元后使用精确小数比较: - 微信 V3 读取 `amount.total` 分,微信 V2 读取 `total_fee` 分。 - 支付宝回调适配需把验签后的 `total_amount`、`trade_no` 一并传入业务事件;不能只凭订单号确认支付。 - 回调金额、币种或订单状态不一致时不确认邮寄,运费单进入异常并告警。 - 重复回调若订单号、金额和渠道交易号一致则直接幂等成功;同订单号出现不同交易号或金额时转人工复核。 余额支付由 `CloudFreightPaymentService::payBalance()` 完成:按固定顺序锁运费单、订单明细和用户账户,在同一事务内扣减 `eb_user.now_money`、写 `UserBill(type=farm_cloud_freight_pay)`、确认邮寄并创建履约。不能照搬现有未显式加锁的余额支付代码。 ### 6. 超时、并发与迟到付款 `CloudFreightExpireJob` 每分钟扫描到期单,并在事务内锁定运费单和订单明细: 1. 已支付时不处理。 2. 未支付且仍在选择期时,运费单转已超时,清除临时邮寄选择,订单明细回到待选择。 3. 未支付且已过选择截止时,运费单转已超时,并通过同一去向服务自动转为代销。 4. 事务提交后通过 Outbox 尝试渠道关单;关单失败可重试,但不回滚本地超时结果。 由于当前 CRMEB 没有统一关单接口,系统必须始终接受“超时任务与成功回调并发”的可能: - 锁内只有一个流程能先完成状态迁移。 - 如果渠道支付已成功,但本地已经取消、超时或自动代销,则记录为迟到支付,禁止把去向改回邮寄。 - 迟到支付自动创建全额退款单;退款成功前在用户订单中心显示“退款处理中”。 - `pay_result` 只查询本地运费单状态,不以客户端支付 SDK 的成功回调作为最终结果。 ### 7. 退款设计 新建 `eb_farm_cloud_freight_refund`,V1 只允许整张运费单全额退款,不做部分运费退款。 允许退款的场景: - 支付成功回调迟于运费单超时/取消或订单明细已自动代销。 - 首次云仓订单在发货前完成全量退款。 - 平台在发货前取消邮寄履约。 - 审计确认的重复扣款或错误支付。 已发货后不因用户改选退运费;平台责任取消、物流未揽收或其他异常由客服按异常单审核后全额退款。 在线退款使用原支付驱动和原商户订单号,参数至少包含 `pay_price`、`refund_price`、唯一 `refund_id` 和原因。余额退款在事务内增加用户余额并写 `UserBill(type=farm_cloud_freight_refund)`。所有退款重试复用同一 `refund_no`,状态使用待提交、处理中、成功、失败待重试和人工复核。 ### 8. 最小代码影响 | 类型 | 文件 | 作用 | | --- | --- | --- | | 新建 | `app/common/model/farm/cloud/CloudFreightOrder.php` | 运费支付参数和关联 | | 新建 | `app/common/model/farm/cloud/CloudFreightRefund.php` | 独立退款记录 | | 新建 | 对应 DAO/Repository | 加锁查询、当前运费单和退款幂等 | | 新建 | `crmeb/services/farm/cloud/CloudFreightQuoteService.php` | 复用 CRMEB 运费模板并生成报价快照 | | 新建 | `crmeb/services/farm/cloud/CloudFreightPaymentService.php` | 创建、发起支付、余额支付、成功事务和迟到支付 | | 新建 | `crmeb/services/farm/cloud/CloudFreightRefundService.php` | 在线/余额全额退款和重试 | | 新建 | `crmeb/listens/pay/CloudFreightPaySuccessListen.php` | 接收独立支付事件 | | 修改 | `app/event.php` | 注册 `pay_success_farm_cloud_freight` | | 修改 | `crmeb/services/AlipayService.php` | 将验签后的 `total_amount` 和 `trade_no`安全传给监听器 | | 新建 | `crmeb/listens/farm/timer/CloudFreightExpireListen.php` + `crmeb/jobs/farm/CloudFreightExpireJob.php` | 监听器按间隔投递;Job 处理超时、回待选择/自动代销和关单 Outbox | | 新建 | `crmeb/listens/farm/timer/CloudFreightPaymentRepairListen.php` + `crmeb/jobs/farm/CloudFreightPaymentRepairJob.php` | 监听器按间隔投递;Job 修复支付中、迟到回调和本地状态 | | 新建 | `crmeb/jobs/farm/RefundCloudFreightJob.php` | 渠道退款提交与重试 | 不修改微信/支付宝支付驱动的下单与退款主流程,不调用 `StoreOrderRepository::paySuccess()`,不把运费单加入普通商城财务、佣金、优惠或商户分账。 ### 9. 采用方案与替代方案 | 方案 | 结论 | 原因 | | --- | --- | --- | | 独立运费单 + 现有支付驱动 + 独立回调事件 | 采用 | 复用渠道能力,同时保持商品本金、商户货款和运费审计边界 | | 修改原云仓商品订单金额后二次支付 | 否决 | 已支付订单金额、退款分摊和商户货款都会失真 | | 生成一张普通商城虚拟商品订单 | 否决 | 会误入商品销量、佣金、优惠、商户财务和发货状态 | | 只信前端支付成功结果 | 否决 | 客户端结果可中断或伪造,不能形成最终支付事实 | | 超时后忽略迟到回调 | 否决 | 平台已收款但用户未获得邮寄权益,会造成资金差异 | | V1 支持部分运费退款 | 否决 | 当前一条明细只有一个整体去向,全额退款更可审计 | ### 10. 必测场景 1. 微信、支付宝和余额支付各完成一次,运费不进入商品本金或商户货款。 2. 同一成功回调重放十次,只确认一次邮寄、创建一张履约单。 3. 回调金额或交易号冲突时不确认邮寄并产生告警。 4. 支付成功事务中创建履约失败时,本地支付确认和去向迁移全部回滚,回调重试可恢复。 5. 超时任务与成功回调并发时,只产生邮寄或超时结果之一。 6. 仍在选择期超时后回到待选择,超过截止时间超时后只自动代销一次。 7. 已自动代销后收到迟到成功回调,不改去向并自动发起一次全额退款。 8. 未支付运费单切换支付渠道时旧单失效,新单使用新订单号。 9. 首次商品退款与运费退款并发时不会漏退、重复退或发货。 10. 余额支付和普通余额消费并发时余额正确,账单末余额与账户一致。 11. 在线退款任务重复执行时始终复用同一退款单号。 12. 普通商城订单、充值、普通退款和原支付回调完整回归。 ## 六、SPIKE-006:服务端认证、路由与移动布局 ### 1. 要回答的问题 1. 现场人员是否需要新建独立账号系统。 2. 现有客服 Token 能否同时承载 PC 客服和移动现场作业。 3. 客服端是否已有可复用的菜单权限和数据范围能力。 4. 三栏客服工作台能否直接改成移动响应式页面。 5. 同一前端工程如何形成互不干扰的 PC/移动双入口。 ### 2. 只读源码、数据库与运行态证据 | 证据 | 当前事实 | 设计含义 | | --- | --- | --- | | `route/service.php` | 后端统一使用 `/ser` 前缀,公开登录接口和受 `ServiceTokenMiddleware` 保护的固定路由都在一个文件中 | V1 继续局部扩展该路由文件,不同时重构路由加载器 | | `ServiceTokenMiddleware` | JWT 的 `jti` 类型固定为 `service`,每次请求重新读取 `store_service`;当前只校验 `is_open` 和商户状态,没有农业职责、权限或对象范围 | 身份可复用,但必须增加账号状态加固和独立农业授权层 | | `StoreServiceRepository` | Token 使用 `service_{token}` 缓存并按活动请求续期 | PC 与移动入口可共享同一 Token,不需要第二种 guard | | `eb_store_service` 实际表结构 | 主键为 `service_id`,含 `mer_id`、`account`、`pwd`、`is_open`、`status`、`is_del`;`account` 当前只有普通索引 | 可作为统一人员身份,但上线迁移前必须清理重复账号并增加唯一约束 | | 平台/商户客服维护控制器 | 商户端创建时校验全局账号重复;平台端当前没有完整维护 `account`、`pwd`、`is_open` | 平台管理端需扩展现有客服表单,不另建人员账号表 | | `app/controller/service/Service.php` | 现有客服接口有按会话/商户约束的查询,也有按订单 ID 直接读取的宽松模式 | 新农业接口禁止复制宽松模式,查询和写入都必须在 Repository 层套用范围 | | `smartfarm_service/package.json` | Vue 2.6、Vue Router 3、Vuex 3、Element UI 2;没有移动 UI 库 | PC 可复用 Element UI;移动作业页使用独立轻量组件层,不把 Element 表格缩成手机页面 | | `src/router/index.js` | 只有 `/kefu/login`、`/kefu/dashboard` 和 404,均为静态路由 | 农业路由可继续静态注册并按 `meta.permission` 控制,不需要引入后台动态菜单体系 | | `src/views/kefu/pc/index.vue` | 三栏聊天页最小宽度 `1000px`,内容区固定在 `1000-1200px`,最低高度 `600px` | 不能把现有客服页直接响应式压缩为现场移动端 | | 运行页面 | `/kefu/dashboard` 未登录时实际跳转 `/kefu/login?redirect=%2Fkefu%2Fdashboard` | 正式前端路由前缀是 `/kefu`,旧文档中的 `/service/dashboard` 不作为实现依据 | ### 3. 现状安全前置项 以下属于复用身份前必须处理的基线加固,不是另建认证系统: 1. `ServiceTokenMiddleware` 每次请求同时校验 `is_open=1`、`status=1`、`is_del=0`;商户账号继续校验商户存在且启用。 2. 移除 `userType` 宏中无意义的未定义变量捕获,并为中间件增加认证回归测试。 3. 迁移前检查所有非空 `store_service.account` 是否重复;通过后增加唯一索引,空值继续允许多个。 4. 平台端客服维护表单补齐账号、密码、PC/移动登录开关;密码仍写入现有 `pwd`,不复制凭据。 5. 现有 `getOrderInfo` 等宽松接口单列为基线修复任务;农业 Controller 不通过“先按 ID 查出,再在页面隐藏”实现权限。 ### 4. 冻结身份与授权架构 V1 采用“一个身份、两个工作区、两层授权”: ```text eb_store_service └─ ServiceTokenMiddleware:确认是谁、账号是否仍有效 └─ FarmServicePermissionMiddleware:确认能做什么 └─ FarmServiceScopeRepository:确认能操作哪个对象 ``` - PC 客服和移动现场端复用 `store_service`、`service` JWT 和 `SerToken`。 - 不把职责和长期范围写入 JWT;调岗、停用或撤销范围后,下一次请求立即生效。 - 职责到权限码的映射使用版本化代码配置,避免 V1 再造一套动态菜单/角色系统。 - 具体农场、区域、栏舍、仓库、生产批次和任务授权使用数据库关系。 - 路由权限只解决“动作权限”,Repository 查询范围和对象断言解决“数据权限”,两者必须同时通过。 - 默认拒绝:没有农业档案、没有职责、范围过期或对象无法沿关系链落入范围时均返回 403。 - 平台客服可被授予全部或指定商户范围;商户客服的最大边界永远是自身 `mer_id`,授权记录不能扩大该租户边界。 - 结算、回购、规则修改、审核发布和最终异常方案仍只在平台管理端执行。 ### 5. 职责与权限码 | 职责码 | 主要权限码 | 默认对象范围 | | --- | --- | --- | | `platform_customer_service` | `farm.order.read`、`farm.cloud.order.read`、`farm.exception.read`、`farm.exception.propose` | 全部或指定商户 | | `merchant_customer_service` | `farm.order.read`、`farm.cloud.order.read`、`farm.exception.read`、`farm.exception.propose` | 当前商户 | | `crop_operator` | `farm.task.read`、`farm.scan.resolve`、`farm.production.event.write`、`farm.output.write`、`farm.exception.report` | 农场、区域、地块、生产批次或任务 | | `livestock_operator` | `farm.task.read`、`farm.scan.resolve`、`farm.production.event.write`、`farm.asset.health.write`、`farm.output.write`、`farm.exception.report` | 农场、区域、栏舍、资产、生产批次或任务 | | `warehouse_operator` | `farm.task.read`、`farm.scan.resolve`、`farm.warehouse.receive`、`farm.fulfillment.pack`、`farm.fulfillment.ship`、`farm.pickup.verify`、`farm.exception.report` | 仓库、产出批次、履约单或任务 | | `farm_reviewer` | `farm.production.review`、`farm.output.review`、`farm.exception.read` | 指定农场或业务域 | 同一人员可有多个职责。`write` 权限允许保存草稿和提交本人记录,不自动包含审核权限;`propose` 只允许提交建议,不允许直接执行财务或最终处置。 ### 6. 数据结构 新增两类农业授权数据: 1. `eb_farm_service_profile`:一对一绑定 `service_id`,记录农业工作区启用状态、默认工作区、移动端开关、授权版本和最近变更时间。 2. `eb_farm_service_scope`:记录 `service_id + duty_code + scope_type + scope_id`、有效期、状态和授权人。 `scope_type` 固定为 `all`、`merchant`、`farm`、`area`、`plot`、`barn`、`asset`、`warehouse`、`production_batch`、`output_batch`、`fulfillment`、`task`。范围解析只允许从当前业务对象沿已定义关系链向上匹配,禁止客户端提交一个属于授权农场但与当前订单无关的范围 ID 来绕过校验。 ### 7. 登录后上下文 保留原登录响应,新增: ```text GET /ser/farm/context ``` 返回当前人员可进入的 `workspaces`、职责码、权限码、范围摘要、`scope_version`、默认入口和功能开关。前端只用它决定显示和跳转;后端每个请求仍重新校验。旧客服账号没有农业档案时继续进入 `/kefu/dashboard`,不会因农业功能上线改变原工作流。 登录后入口规则: 1. 只有传统客服能力:进入 `/kefu/dashboard`。 2. 只有现场能力:进入 `/kefu/farm/work/tasks`。 3. 同时拥有多个工作区:进入 `/kefu/entry` 选择,记住的默认入口仅用于体验,不作为授权依据。 4. 访问无权限路由:显示无权限页,不退回登录页;Token 失效才重新登录。 ### 8. PC/移动双入口 | 路由族 | 布局 | 页面 | | --- | --- | --- | | `/kefu/dashboard` | 原三栏客服页 | 原客服会话,不改布局 | | `/kefu/farm/desk/*` | 新农业桌面布局 | 农业订单、云仓订单、异常协同、审核 | | `/kefu/farm/work/*` | 新移动优先布局 | 我的任务、扫码、过程记录、产出、仓储履约、异常上报 | 两个新布局位于同一 `smartfarm_service` 构建中,复用请求、Token、上传和格式化能力;不把现场人员放入消费者 `smartfarm_user`,也不新建第六个前端项目。 移动端不新增整套 UI 框架。V1 使用现有主题色、SVG/Iconfont、请求和上传能力,新增少量领域组件;日期、时间、数量等优先使用移动浏览器原生控件。扫码采用 `@zxing/browser` 的摄像头/图片解码能力,并始终提供手工输入编码的降级入口;摄像头权限被拒绝、浏览器不支持或非 HTTPS 时仍可继续作业。 ### 9. 最小文件影响 后端: - 修改 `ServiceTokenMiddleware.php`、平台客服维护 Controller/Validate/Repository 和 `route/service.php`。 - 新增 `FarmServicePermissionMiddleware`、`FarmServicePolicy`、`FarmServiceProfile` 与 `FarmServiceScope` 的 Model/DAO/Repository。 - 新增 `service/farm/Common`、`Order`、`Cloud`、`Task`、`Production`、`Warehouse`、`ExceptionEvent` Controller。 - 所有领域 Repository 增加 `scopeQuery()` 或 `assertScopedObject()`,Controller 不自行拼接范围 SQL。 前端: - 保留 `src/views/kefu/pc/index.vue`。 - 新增 `src/router/farm.js`、`src/store/modules/farmContext.js`、`src/api/farm.js`。 - 新增 `src/layout/farmDesktop/index.vue`、`src/layout/farmMobile/index.vue`。 - 新增 `src/views/farm/desk/*` 与 `src/views/farm/work/*`,并复用 `request.js`、`auth.js`、上传、SVG 和格式化能力。 ### 10. 采用方案与替代方案 | 方案 | 结论 | 原因 | | --- | --- | --- | | 复用客服身份 + 独立农业授权 | 采用 | 减少凭据与登录系统重复,同时补齐最小权限 | | 新建现场人员账号系统 | 不采用 | 增加登录、密码、停用、审计和运维成本 | | 把现场页放入用户 uni-app | 不采用 | 消费者身份与工作人员权限边界混淆 | | 把三栏客服页做全响应式 | 不采用 | 固定宽度和交互密度不适合手机现场操作 | | 新建独立现场前端项目 | V1 不采用 | 当前页面量可由同项目独立布局承载 | | 复用平台/商户动态菜单权限 | 不采用 | 客服工程没有该运行链路,接入成本大于稳定权限码配置 | ### 11. 必测场景 1. 原客服账号无农业档案时登录、聊天和退出行为不变。 2. `status`、`is_open` 或 `is_del` 变化后,已有 Token 下一请求立即失效。 3. 同账号在 PC 和手机并行使用时 Token 续期和退出策略符合预期。 4. 无职责、无权限码、范围过期和对象越界分别返回稳定 403 错误。 5. 商户客服不能通过指定其他商户订单 ID 越权读取。 6. 现场人员可查看授权农场任务,但不能操作同农场内未分配且没有上级范围覆盖的限制对象。 7. 撤销范围后,已打开页面的下一次提交失败并刷新上下文。 8. PC 客服工作台在 `1280x800` 保持原布局;农业桌面页在 `1280x800` 无横向溢出。 9. 移动作业页在 `360x800`、`390x844`、`430x932` 下无文本或按钮重叠。 10. 摄像头成功、权限拒绝、非 HTTPS、无摄像头和图片识别失败均有可继续的手工输入路径。 11. 网络失败后表单草稿保留;重复提交使用同一 `request_id` 不生成重复记录。 12. 原客服订单查询宽松路径修复后,正常会话内订单仍可查看,跨商户 ID 被拒绝。 ## 七、SPIKE-007:地图跨端兼容边界 ### 1. 要回答的问题 1. 平台管理端、用户 H5/微信小程序和服务/现场端是否能统一使用同一地图供应商。 2. 农场、区域、地块和自提点需要保存哪些坐标,采用哪一种坐标系。 3. 浏览器地图 Key、服务端 WebService Key 和签名密钥如何隔离。 4. 地图不可用、用户拒绝定位或供应商限额时,核心业务是否还能继续。 5. 当前 CRMEB 地图实现可以复用到什么程度,哪些现状缺陷必须单列整改。 ### 2. 只读源码、数据库与运行态证据 | 证据 | 当前事实 | 设计含义 | | --- | --- | --- | | 后端公开接口 | `route/api/notLogin.php` 已有 `GET /api/lbs/geocoder`,`app/controller/api/Common.php::lbs_geocoder` 接收 `lat,long` 并调用腾讯地图 | 现有用户定位链路可兼容保留,但不作为新农业 API 的字段规范 | | 后端腾讯地图依赖 | `composer.json` 使用 `joypack/tencent-map:^1.0`,该依赖支持地址、逆地址和坐标转换 | 可参考返回结构,不能直接作为新农业地图适配器 | | 旧依赖传输安全 | 当前 `joypack/tencent-map` 请求实现关闭 TLS 证书和主机校验 | 新农业服务必须使用开启 TLS 校验的 HTTP 客户端 | | 平台地图组件 | `smartfarm_admin/src/components/map/Map.vue` 动态加载腾讯 GL JS,并在浏览器直接调用逆地址 JSONP | 视觉和点选交互可复用,WebService 查询应改走后端代理 | | 商户地图组件 | `smartfarm_mer/src/components/map/map.vue` 支持点选和 `pickMode` | 当前农业口径不要求商户维护农场点位,不新增商户农业地图页面 | | 商户坐标缺陷 | 商户资料表单把 `lat` 标为经度、`long` 标为纬度,提交又写入 `log`;数据库实际约定 `long` 为经度、`lat` 为纬度 | 作为 CRMEB 基线缺陷单独修复并回归,不沿用到新接口 | | 用户端定位 | `store/modules/location.js` 使用 `uni.getLocation({type: "wgs84"})` 后调用腾讯逆地址接口 | 与腾讯地图和 uni-app `` 常用的 GCJ02 存在坐标系错配风险 | | 用户端地图能力 | 已使用 `uni.getLocation`、`uni.chooseLocation`、`uni.openLocation`,`manifest.json` 声明 Maps、Geolocation 和微信位置权限 | 农场位置展示和外部导航无需新增原生地图插件 | | 用户端版本 | 当前 `@dcloudio/uni-app` 为 `2.0.2-4080720251210002` | V1 不以升级 uni-app 作为农场地图上线前提,现有定位链路另行回归 | | 服务端现状 | `smartfarm_service` 没有地图组件或定位依赖 | 先提供地址、位置页和外部导航;内嵌地图只能作为增强能力 | | 数据库配置 | `eb_system_config` 已定义 `tx_map_key`,当前 `eb_system_config_value` 没有平台值 | 设计可复用配置名,但环境必须在联调前完成实际配置检查 | | 现有坐标字段 | `eb_delivery_station` 使用 `lng/lat`,`eb_merchant` 使用 `long/lat`,`eb_store_group` 和 `eb_user_address` 使用 `longitude/latitude`,类型均为字符型 | 新农业域必须建立唯一命名和精度规范,在适配边界转换旧字段 | 官方兼容性依据: - [uni.getLocation](https://uniapp.dcloud.net.cn/api/location/location) 说明 `` 相关场景应明确使用 `gcj02`,不同端的定位配置和版本兼容性需要分别验证。 - [uni-app map 组件](https://uniapp.dcloud.net.cn/component/map.html) 支持 App、H5 和微信小程序,但 Key、域名白名单、组件层级和供应商额度属于运行环境配置。 - [腾讯位置服务坐标说明](https://developer.cloud.tencent.com/article/1361312) 表明腾讯地图国内服务使用 GCJ02。 ### 3. 冻结供应商与坐标规范 `G3-CODE-008` 和 `G3-EXT-001` 确认为: 1. V1 默认地图供应商为腾讯地图。平台管理端、用户端和服务端展示使用同一供应商,避免跨供应商坐标偏移。 2. 代码保留 `MapProviderInterface`,V1 不提供运营侧多供应商切换页面,也不同时实现第二家供应商。 3. 新农业业务事实坐标统一为 `GCJ02`。 4. 新表和新 API 只使用 `longitude`、`latitude`、`coordinate_system`,不再新增 `long`、`lng` 或含义不明确的 `lat/long` 契约。 5. 旧 CRMEB 字段只在 Repository 或前端适配层转换,不通过新接口继续扩散。 6. V1 只保存点位和示意材料,不建设专业多边形编辑、路线规划、电子围栏、卫星图或人员实时轨迹。 ### 4. 数据字段与公开边界 统一点位字段: | 字段 | 类型 | 规则 | | --- | --- | --- | | `longitude` | `decimal(10,7)` | 可空;非空时范围 `-180` 至 `180` | | `latitude` | `decimal(10,7)` | 可空;非空时范围 `-90` 至 `90` | | `coordinate_system` | `varchar(8)` | 点位存在时固定 `GCJ02` | 约束: - 经度和纬度必须同时为空或同时非空,禁止保存半个点位。 - 不使用 `0,0` 表示“未配置”。 - 接口以十进制字符串返回坐标,避免 JavaScript 浮点序列化擅自改变精度。 - 农场和统一自提点必须有真实点位后才能启用或公开。 - 区域、地块和栏舍默认继承农场公开点位,使用示意图表达内部位置;确有必要时可保存内部中心点,但不要求每个对象都有坐标。 - 用户公开接口返回农场公开点位和公开地址,不返回敏感地块、栏舍、仓库内部精确点位。 - V1 不建立空间索引,不以 GIS 多边形计算面积、相交或容量。 新增 `eb_farm_pickup_point` 统一承载云仓和农业产出的线下自提点。V1 由平台维护,保存名称、业务类型、地址、营业时间、联系人、点位、状态和版本;云仓活动只引用已启用且点位完整的自提点。 ### 5. Key 与调用边界 | 配置 | 用途 | 可见范围 | | --- | --- | --- | | `tx_map_key` | 腾讯 GL JS、`uni.openLocation` 相关客户端展示 Key | 可按现有方式提供给受控前端,配置域名/小程序白名单 | | `farm_tencent_map_server_key` | 地理编码、逆地址、地点建议等 WebService 调用 | 仅后端读取,不返回前端 | | `farm_tencent_map_server_sk` | WebService 签名密钥,启用签名时使用 | 仅后端密钥配置,后台只显示掩码 | 安全规则: - 文档、日志、错误响应、前端构建产物和数据库导出中不记录真实 Key 或 SK。 - 服务端 WebService 调用使用 Guzzle 或项目等价 HTTP 客户端,必须开启 TLS 证书与主机校验。 - 后端限制请求频率、输入长度和调用来源;地点建议不得成为无认证公共代理。 - 客户端 Key 与服务端 Key 分离额度和白名单,不能把服务端 SK 写入 `manifest.json`。 - 腾讯地图生产额度、商业授权和采购判断属于发布前外部配置门,不阻塞当前设计,也不默认承诺付费购买。 ### 6. 后端抽象与接口 推荐目录: ```text crmeb/services/farm/support/map/ MapProviderInterface.php TencentMapProvider.php FarmMapService.php GeoPoint.php ``` 职责: - `GeoPoint` 负责坐标范围、成对空值和坐标系校验。 - `TencentMapProvider` 只处理腾讯请求签名、超时、响应转换和供应商错误映射。 - `FarmMapService` 负责缓存、限流、公开字段裁剪和供应商选择。 - 领域 Repository 只保存规范化点位,不直接调用腾讯 SDK。 平台管理端增加只读辅助接口: ```text GET farm/map/geocode GET farm/map/reverse_geocode GET farm/map/suggest ``` 接口必须经过平台认证和操作权限,返回统一地址结构与 `GCJ02` 坐标,不透传供应商完整原始响应。农场、自提点等业务写接口仍自行验证点位,不能因为点位来自地图辅助接口就跳过校验。 旧 `/api/lbs/*` 保持响应兼容,后续通过同一 `FarmMapService` 收口并补充参数验证、TLS 和错误映射。该工作是 CRMEB 基线整改,必须单独回归现有首页定位、地址选择和附近门店,不与新农业页面首次提交绑在同一个大改任务中。 ### 7. 四端页面策略 #### 平台管理端 - 保留 `src/components/map/Map.vue` 的腾讯 GL JS 加载、点选和标记能力。 - 以向后兼容方式增加规范化 `longitude/latitude` 输出和“服务端地理编码”模式。 - 新增 `FarmMapPicker.vue` 组合地址搜索、点选、坐标只读值、重新定位和失败状态。 - 农场和自提点编辑使用真实点位;区域/地块编辑以农场继承点位和上传示意图为主。 - 地图依赖不可用时允许保存草稿地址,但禁止启用或发布缺少有效点位的农场和自提点。 #### 商户端 - V1 不新增农场地图模块。 - 商户资料页现有 `lat/long/log` 缺陷列为基线修复任务,修复时保持后端旧字段兼容并增加回归测试。 #### 用户端 - 新增农场位置卡片和全屏位置页,展示服务端保存的农场 `GCJ02` 点位。 - 用户点击“导航”时调用 `uni.openLocation`,显式传入农场点位,不要求先获取用户当前位置。 - “距我多远/附近农场”不是 V1 必要能力;只有用户主动使用时才申请定位权限。 - 用户拒绝权限、H5 定位失败或当前 uni-app 版本不支持定位时,仍可查看地址、示意图并复制地址。 - 现有 `wgs84 -> 腾讯逆地址` 链路单独列入基线整改,不能在农业页面任务中静默改动全站定位行为。 #### 服务/现场端 - 任务和履约页先显示结构化地址、位置摘要、复制地址与导航按钮。 - 可增加轻量腾讯 GL 地图作为增强,不引入完整移动地图框架。 - 地图加载失败、客户端 Key 缺失或浏览器不支持时,不阻断任务查看、扫码、记录或提交。 ### 8. 可用性、超时与降级 | 场景 | V1 处理 | | --- | --- | | 服务端地图配置缺失 | 后台辅助接口返回 `MAP_NOT_CONFIGURED`;草稿可保存地址,启用/发布被阻止 | | 坐标非法或坐标系不支持 | 写接口拒绝,禁止自动保存 `0,0` | | 腾讯地图超时或 5xx | 连接超时 2 秒、总超时 5 秒,仅超时/5xx 重试 1 次 | | 供应商配额耗尽 | 返回 `MAP_QUOTA_EXCEEDED`,告警并允许人工填写地址/坐标 | | 用户端地图脚本失败 | 展示地址、示意图、复制和重试入口 | | 用户拒绝定位 | 不影响农场查看和导航;只关闭距离类增强 | | 服务端现场地图失败 | 任务与事实录入继续,只隐藏地图增强 | 地理编码和逆地址结果可在 Redis 按规范化查询与坐标短期缓存。不得把供应商失败静默转换为空地址或默认坐标。 ### 9. 采用方案与替代方案 | 方案 | 结论 | 原因 | | --- | --- | --- | | 腾讯地图 + 供应商接口抽象 + GCJ02 | 采用 | 与现有管理端、uni-app 和配置最一致,跨端偏移最小 | | 客户端直接调用全部 WebService | 否决 | 暴露服务端额度和签名边界,难以统一限流与错误处理 | | 新农业接口继续混用 `lng/long/latitude` | 否决 | 现有字段已经不一致,会继续制造方向错误 | | V1 强制升级 uni-app 后再做地图 | 否决 | 农场展示可不依赖当前定位,升级风险不应阻塞主流程 | | V1 建专业 GIS 多边形编辑器 | 否决 | 租地容量依赖业务占用记录,不需要用地图几何替代 | | V1 同时接腾讯和另一家地图 | 否决 | 增加坐标转换、测试、额度和前端体积,当前没有业务收益 | | 用户端默认自动获取位置 | 否决 | 增加权限阻断和兼容风险,农场导航只需要目标点位 | ### 10. 必测场景 1. 农场和自提点经纬度成对保存,边界值和错误方向均被正确校验。 2. 新 API 始终返回 `longitude/latitude/coordinate_system=GCJ02`,不返回旧字段别名。 3. 平台地址搜索、地图点选和手工坐标三种入口得到同一规范化结果。 4. 服务端 Key/SK 不出现在响应、日志、前端构建和导出中。 5. 地图未配置、超时、5xx、配额耗尽和非法响应均映射稳定错误码。 6. 地图不可用时草稿可保存,但农场/自提点无法启用;已有交易与现场任务仍可继续。 7. 用户 H5、微信小程序和 App 使用保存的 GCJ02 点位打开导航,拒绝当前定位权限仍可查看地址。 8. 用户端地图加载失败后地址、示意图、复制和重试入口可用。 9. 服务端移动页地图失败不影响扫码、记录、上传和提交。 10. 公开农场接口不泄露内部地块、栏舍或仓库精确点位。 11. 旧 `/api/lbs/geocoder`、首页定位、地址选择和附近门店在适配器整改后响应兼容。 12. 商户资料经纬度缺陷修复后,旧数据读取和保存方向正确,提交不再出现 `log` 字段。 ## 八、专项验证结论 ### 1. 七项专项验证 `SPIKE-001` 至 `SPIKE-007` 已全部完成。四端 `108` 个页面低保真和 `15` 条跨端 Flow 随后完成并通过结构审计,当前阶段是用页面和 Flow 反向冻结数据、API、事件任务、代码文件与测试契约。后续发现新的事实疑点仍可新增专项验证,但不能用“开发时再看”代替明确记录。 ### 2. 支付、物流与消息能力补充核验 为关闭 `G3-EXT-003`,本轮又对 CRMEB 现有第三方能力边界进行了只读源码核验: | 能力 | 当前源码证据 | V1 复用结论 | | --- | --- | --- | | 支付 | `crmeb/services/PayService.php:29` 按支付类型分派,微信/支付宝接口均携带业务 `attach`;各驱动实现 `crmeb/services/pay/PayInterface.php` | 云仓首次订单和独立补运费单复用现有支付/退款驱动,以不同 `attach` 分流;不新增支付供应商 | | 物流查询与电子面单 | `crmeb/services/express/storage/Express.php:122` 提供快递公司、轨迹、面单和寄件接口;`app/common/repositories/store/order/StoreOrderRepository.php:1889` 已封装发货、消息和物流事件 | 农业履约保留独立业务履约单,通过薄适配调用现有快递能力;未开通面单服务时允许人工录入快递公司和单号,不能阻断履约 | | 短信与微信消息 | `crmeb/services/sms/Sms.php:25` 使用驱动管理器;`crmeb/services/RoutineTemplateService.php:37` 按模板编码发送小程序消息 | 新增农业模板编码、队列消费者和 `eb_farm_notification_log`,继续使用现有通道;通道失败不得回滚订单、结算或现场事实 | 最终边界: 1. V1 不自建支付、快递轨迹、电子面单、短信或微信消息供应商实现。 2. 领域 Service 只调用 SmartFarm 适配层,不直接散落调用第三方 SDK。 3. 第三方超时统一映射稳定错误、进入重试和告警;消息发送属于提交后副作用。 4. 未配置短信、模板消息或电子面单时,站内状态、人工快递单号和业务流程仍可使用。 5. 生产账号、额度、模板审核和可能的商业开通费用属于上线前环境门;发生付费采购时仍须由项目方确认。 ### 3. 数据库参照完整性补充核验 对当前 `www_smartfarm_co` 数据库执行只读 `information_schema.REFERENTIAL_CONSTRAINTS` / `KEY_COLUMN_USAGE` 核验,物理外键约束数量为 `0`。现有 CRMEB 表间关系依赖应用层校验、索引、事务与约定删除顺序,而不是数据库级 `FOREIGN KEY`。 V1 因此冻结为: 1. 90 张农业扩展表不新增物理外键,避免同一业务库混用两套删除、升级和恢复语义。 2. 每个 `RID/CID` 引用字段必须在 `34` 中给出必要普通/组合索引,并在 Repository 或领域 Service 写事务内验证对象存在、状态、归属和版本。 3. 订单、账本、库存、生产和溯源事实只作废或冲正,不因主数据停用而物理删除。 4. `verify_before/verify_after` 与日常对账增加必需引用的孤儿扫描;发现孤儿时阻断发布或进入修复队列,不静默补默认对象。 5. 如果未来要改用物理外键,必须作为全库级架构迁移单独评审,不能只给农业表局部添加。